@shware/workflow 4.0.0 → 4.1.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.
- package/README.md +141 -13
- package/dist/apns.cjs +3 -2
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +3 -2
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/function-url.cjs +3 -11
- package/dist/aws/function-url.cjs.map +1 -1
- package/dist/aws/function-url.d.cts.map +1 -1
- package/dist/aws/function-url.d.mts.map +1 -1
- package/dist/aws/function-url.mjs +3 -11
- package/dist/aws/function-url.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -0
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +2 -1
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +2 -1
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +2 -1
- package/dist/aws/index.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +1 -0
- package/dist/cloudflare/index.d.cts +2 -2
- package/dist/cloudflare/index.d.mts +2 -2
- package/dist/cloudflare/index.mjs +2 -2
- package/dist/fcm.cjs +2 -1
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +2 -1
- package/dist/fcm.mjs.map +1 -1
- package/dist/host/auth.cjs +15 -15
- package/dist/host/auth.cjs.map +1 -1
- package/dist/host/auth.d.cts +16 -2
- package/dist/host/auth.d.cts.map +1 -1
- package/dist/host/auth.d.mts +16 -2
- package/dist/host/auth.d.mts.map +1 -1
- package/dist/host/auth.mjs +15 -16
- package/dist/host/auth.mjs.map +1 -1
- package/dist/host/channels.cjs +2 -1
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +2 -1
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/http.cjs +37 -18
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +5 -1
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +5 -1
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +37 -18
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/journey-host.cjs +1 -0
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +12 -0
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +12 -0
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +1 -0
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/jwt.cjs +5 -9
- package/dist/jwt.cjs.map +1 -1
- package/dist/jwt.d.cts +1 -1
- package/dist/jwt.d.cts.map +1 -1
- package/dist/jwt.d.mts +1 -1
- package/dist/jwt.d.mts.map +1 -1
- package/dist/jwt.mjs +5 -9
- package/dist/jwt.mjs.map +1 -1
- package/dist/store/sql.cjs +2 -1
- package/dist/store/sql.cjs.map +1 -1
- package/dist/store/sql.d.cts.map +1 -1
- package/dist/store/sql.d.mts.map +1 -1
- package/dist/store/sql.mjs +2 -1
- package/dist/store/sql.mjs.map +1 -1
- package/dist/transports/sigv4.cjs +2 -3
- package/dist/transports/sigv4.cjs.map +1 -1
- package/dist/transports/sigv4.d.cts.map +1 -1
- package/dist/transports/sigv4.d.mts.map +1 -1
- package/dist/transports/sigv4.mjs +2 -3
- package/dist/transports/sigv4.mjs.map +1 -1
- package/dist/webpush.cjs +11 -20
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +11 -20
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
package/dist/aws/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const executions = store.executions;\n if (executions === undefined) {\n throw new Error(\n 'journeyLambda needs a store with an execution registry (PostgresJourneyStore)'\n );\n }\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return (await store.reporting?.snapshotSegments(Date.now())) ?? [];\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const executions = store.executions;\n if (executions === undefined) {\n throw new Error(\n 'journeyLambda needs a store with an execution registry (PostgresJourneyStore)'\n );\n }\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return (await store.reporting?.snapshotSegments(Date.now())) ?? [];\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDA,MAAa,mBAAmB;;AAwDhC,SAAS,YAAY,oBAAoC;CACvD,OAAO,mBAAmB,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,GAAG;AAC3D;AAEA,MAAM,QAAQ,MAAe,SAAS,QACpC,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;CAAE;CAAQ,SAAS,EAAE,gBAAgB,mBAAmB;AAAE,CAAC;AAEhG,SAAgB,cACd,SACe;CAEf,MAAM,EACJ,KAAK,SACL,QAAQ,cACR,cACA,aACA,oBACA,GAAG,gBACD;CACJ,MAAM,OAAOA,0BAAAA,kBACX;EAAE,GAAG;EAAa,QAAQ,KAAK,aAAa,QAAQ,MAAM,KAAK,QAAQ;CAAE,GAEzE,EAAE,WAAWC,qBAAAA,WAAW,EAAE,CAC5B;CACA,IAAI,uBAAuB,KAAA,GAAW;EACpC,MAAM,UAAU,KACb,OAAO,CAAC,CACR,UAAU,QAAQ,aAAaC,wBAAAA,iBAAiB,QAAQ,IAAI,kBAAkB;EACjF,IAAI,QAAQ,SAAS,GAAG;GACtB,MAAM,QAAQ,OAAe,GAAG,KAAK,KAAK,KAAK,KAAU,EAAE;GAC3D,MAAM,IAAI,WACR,GAAG,QAAQ,KAAK,MAAM,IAAI,EAAE,GAAG,YAAY,KAAKA,wBAAAA,iBAAiB,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,uCAC3C,KAAK,kBAAkB,EAAE,2CACnE;EACF;CACF;CACA,MAAM,SAAS,gBAAgBC,uBAAAA,aAAa;CAC5C,MAAM,YAAY,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC,UAAU,KAAK,aAAa,SAAS,WAAW,CAAC;CACzF,MAAM,QAAQ,IAAIC,iBAAAA,kBAA8B;;CAGhD,MAAM,mBAAmB,QAAQ,IAAI,+BAA+B;;CAEpE,MAAM,sBAAiD;EACrD,MAAM,UAAU,WAAW;EAC3B,OAAO,QAAQ,KAAK,OAAO,IAAI;GAAE,IAAI;GAAS,WAAW,OAAO,OAAO;EAAE,IAAI,KAAA;CAC/E;;CAGA,MAAM,QACJ,YACyE;EACzE,MAAM,aAAa,MAAM,SAAS;EAClC,IAAI,eAAe,KAAA,GAAW,MAAM,IAAI,MAAM,sCAAsC;EACpF,MAAM,cAAc,EAAE,GAAG,QAAQ;EACjC,WAAW,UAAU,KAAK,MAAM,WAAW;EAC3C,MAAM,EAAE,UAAU,WAAW;EAC7B,MAAM,aAAa,MAAM;EACzB,IAAI,eAAe,KAAA,GACjB,MAAM,IAAI,MACR,+EACF;EAEF,MAAM,cAAc,YAAY,QAAQ,cAAc,kBAAkB;EACxE,MAAM,SAAS,OAAO,aAAqB,YAAsC;GAI/E,IAAI,YAAY,KAAA,KAAa,QAAQ,KAAK,OAAO,GAAG,OAAO,GAAG,YAAY,GAAG;GAE7E,IAAI,UAAU,IAAI,WAAW,GAAG,OAAO,GAAG,YAAY,GAAG,WAAW;GACpE,MAAM,IAAI,MAAM,sDAAsD,aAAa;EACrF;EACA,MAAM,YAAY,IAAIC,sBAAAA,aAAa;GACjC;GACA;GACA;GACA;GACA,GAAI,iBAAiB,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa;GACrD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;EACrD,CAAC;EACD,OAAO;GAAE;GAAO,KAAK;IAAE,GAAG;IAAS,SAAS;GAAU;GAAG;EAAW;CACtE;CAEA,MAAM,MAAM,OAAO,OAAiB,YAAqD;EACvF,MAAM,EAAE,OAAO,KAAK,eAAe,KAAK,OAAO;EAC/C,MAAM,EAAE,YAAY,WAAW;EAO/B,MAAM,SAAS,MAAMC,wBAAAA,oBAAoB,WAAW,IAAI,UAAU,GAAG;GACnE,MAAM;GACN;EACF,CAAC;EACD,IACE,WAAW,KAAA,KACX,OAAO,cAAc,KAAA,KACrB,OAAO,UAAU,KAAK,UAAU,KAAK,GAErC,MAAM,IAAIC,qBAAAA,eAAe,kDAAkD;EAG7E,MAAM,QAAQ,KAAK,qBAAqB,YAAY;GAClD,MAAM,WAAW,cACf,YACA,QAAQ,iBAAiB,qBACzB,KAAK,IAAI,CACX;EACF,CAAC;EACD,OAAOC,qBAAAA,eACL;GACE;GACA;GACA,aAAa,MAAM;GACnB,MAAM,IAAIC,wBAAAA,cAAc,SAAS,OAAO,YAAY,QAAQ,OAAO,QAAQ,UAAU;EACvF,GACA,KAAK,SAAS,KAAK,OAAO,MAAM,CAClC;CACF;CAEA,MAAM,QAAQ,OAAyB,YAAwD;EAC7F,MAAM,QAAQ,YAAwC;GACpD,MAAM,SAAS,MAAM,eAAe,KAAK;GACzC,IAAI;IACF,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;IACnC,MAAM,UAAUC,yBAAAA,UAAU,KAAK;IAE/B,IAAI,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,aAAa,WACpC,OAAO,MAAMC,yBAAAA,aACX,KACE,EACE,OACE,8EACJ,GACA,GACF,GACA,MACF;IAEF,MAAM,KAAK,eAAe,KAAK,cAAc,CAAC;IAC9C,OAAO,MAAMA,yBAAAA,aAAa,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK,GAAG,MAAM;GAC1E,SAAS,OAAO;IACd,QAAQ,MAAM,6BAA6B,KAAK;IAChD,OAAOA,yBAAAA,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG,GAAG,MAAM;GACpE;EACF;EAIA,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;EAC5D,IAAI,WAAW,SAAS,WAAW,QAAQ,OAAO,MAAM;EACxD,OAAO,QAAQ,KAAK,QAAQ,OAAO,EAAE,sBAAsB,EAAE,aAAa,MAAM,GAAG,CAAC;CACtF;;;;;;;CAQA,MAAM,QAAQ,OAAO,YAAkD;EACrE,MAAM,OAAO,EAAE,sBAAsB,EAAE,aAAa,MAAM,GAAG;EAC7D,MAAM,QAAQ,KACZ,kBACA,YAAY;GACV,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;GACnC,MAAM,KAAK,eAAe,KAAK,cAAc,CAAC;GAC9C,MAAMC,oBAAAA,iBAAiB,KAAK,KAAK;EACnC,GACA,IACF;EACA,IAAI,QAAQ;EACZ,MAAM,UAAoB,CAAC;EAC3B,IAAI;EACJ,KAAK,IAAI,IAAI,IAAK,KAAK;GACrB,MAAM,OAAO,MAAM,QAAQ,KACzB,cAAc,KACd,YAAY;IACV,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;IACnC,OAAOC,oBAAAA,mBAAmB,KAAK,OAAO;KAAE;KAAO,OAAA;IAAuB,CAAC;GACzE,GACA,IACF;GACA,SAAS,KAAK;GACd,QAAQ,KAAK,GAAG,KAAK,OAAO;GAC5B,IAAI,CAAC,KAAK,QAAQ,KAAK,eAAe,KAAA,GAAW;GACjD,QAAQ,KAAK;EACf;EACA,MAAM,QAAQ,MAAM,QAAQ,KAC1B,kBACA,YAAY;GACV,MAAM,EAAE,UAAU,KAAK,OAAO;GAC9B,OAAQ,MAAM,MAAM,WAAW,iBAAiB,KAAK,IAAI,CAAC,KAAM,CAAC;EACnE,GACA,IACF;EACA,OAAO;GAAE;GAAO;GAAS;EAAM;CACjC;CAEA,MAAM,WAAA,GAAUC,8BAAAA,qBAAAA,CAAqB,OAAO,OAAgB,YAA4B;EACtF,IAAIC,yBAAAA,mBAAmB,KAAK,GAAG,OAAO,KAAK,OAAO,OAAO;EACzD,IAAIC,sBAAAA,WAAW,KAAK,GAAG,OAAO,IAAI,OAAO,OAAO;EAChD,IACE,OAAO,UAAU,YACjB,UAAU,QACT,MAA6B,SAAA,mBAE9B,OAAO,MAAM,OAAO;EAEtB,MAAM,IAAI,MAAM,8EAA8E;CAChG,CAAC;CAED,MAAM,UAAU,OACd,OACA,YAC8C;EAC9C,MAAM,aAAyB,CAAC;EAChC,IAAI;GAEF,OAAO,MAAM,MAAM,IAAI,kBACrB,QAAQ,OAAO,OAAwC,CACzD;EACF,UAAU;GAGR,MAAM,WAAW,OAAO,MAAM,CAAC,CAAC,OAAO,UAAmB;IACxD,QAAQ,MAAM,uCAAuC,KAAK;GAC5D,CAAC;EACH;CACF;CAEA,OAAO,EAAE,QAAQ;AACnB"}
|
package/dist/aws/index.d.cts
CHANGED
|
@@ -3,6 +3,7 @@ import { JourneyStore, StoreLease } from "../store/index.cjs";
|
|
|
3
3
|
import { ExecutionState, LambdaApi, sdkLambdaApi } from "./lambda-api.cjs";
|
|
4
4
|
import { AwsEngineStep, stepRetry } from "./engine-step.cjs";
|
|
5
5
|
import { FunctionUrlEvent, FunctionUrlResult, fromResponse, isFunctionUrlEvent, toRequest } from "./function-url.cjs";
|
|
6
|
+
import { Authorize, bearerMatches, timingSafeEqual } from "../host/auth.cjs";
|
|
6
7
|
import { JourneyHostOptions } from "../host/journey-host.cjs";
|
|
7
8
|
import { AwsInstances, AwsInstancesOptions, RUN_EVENT_TYPE, RunInput, executionName, isRunInput } from "./instances.cjs";
|
|
8
9
|
import { DurableExecutionInvocationInput, DurableExecutionInvocationOutput } from "@aws/durable-execution-sdk-js";
|
|
@@ -70,5 +71,5 @@ export interface JourneyLambda {
|
|
|
70
71
|
}
|
|
71
72
|
export declare function journeyLambda<Env extends JourneyLambdaEnv>(options: JourneyLambdaOptions<Env>): JourneyLambda;
|
|
72
73
|
//#endregion
|
|
73
|
-
export { AwsEngineStep, AwsInstances, type AwsInstancesOptions, type ExecutionState, type FunctionUrlEvent, type FunctionUrlResult, type LambdaApi, RUN_EVENT_TYPE, type RunInput, executionName, fromResponse, isFunctionUrlEvent, isRunInput, sdkLambdaApi, stepRetry, toRequest };
|
|
74
|
+
export { type Authorize, AwsEngineStep, AwsInstances, type AwsInstancesOptions, type ExecutionState, type FunctionUrlEvent, type FunctionUrlResult, type LambdaApi, RUN_EVENT_TYPE, type RunInput, bearerMatches, executionName, fromResponse, isFunctionUrlEvent, isRunInput, sdkLambdaApi, stepRetry, timingSafeEqual, toRequest };
|
|
74
75
|
//# sourceMappingURL=index.d.cts.map
|
package/dist/aws/index.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/aws/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../../src/aws/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAqDa;;YAGD,mBAAmB,KAAK;iBAEnB,qBAAqB,YAAY,0BAA0B,KAC1E,mBAAmB,MAAM;;EAIzB,KAAK;;EAEL,QAAQ,KAAK,KAAK;IAAY;QAAqB,eAAe;;EAElE,SAAS;;EAET;;;;;;EAMA;;;;;;EAMA;;;;;;iBAOe;EACf;EACA;EACA;EACA;EACA;;iBAGe;;EAEf,UACE,OAAO,iCACP,SAAS,kBACN,QAAQ;;wBAeC,cAAc,YAAY,kBACxC,SAAS,qBAAqB,OAC7B"}
|
package/dist/aws/index.d.mts
CHANGED
|
@@ -3,6 +3,7 @@ import { JourneyStore, StoreLease } from "../store/index.mjs";
|
|
|
3
3
|
import { ExecutionState, LambdaApi, sdkLambdaApi } from "./lambda-api.mjs";
|
|
4
4
|
import { AwsEngineStep, stepRetry } from "./engine-step.mjs";
|
|
5
5
|
import { FunctionUrlEvent, FunctionUrlResult, fromResponse, isFunctionUrlEvent, toRequest } from "./function-url.mjs";
|
|
6
|
+
import { Authorize, bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
6
7
|
import { JourneyHostOptions } from "../host/journey-host.mjs";
|
|
7
8
|
import { AwsInstances, AwsInstancesOptions, RUN_EVENT_TYPE, RunInput, executionName, isRunInput } from "./instances.mjs";
|
|
8
9
|
import { DurableExecutionInvocationInput, DurableExecutionInvocationOutput } from "@aws/durable-execution-sdk-js";
|
|
@@ -70,5 +71,5 @@ export interface JourneyLambda {
|
|
|
70
71
|
}
|
|
71
72
|
export declare function journeyLambda<Env extends JourneyLambdaEnv>(options: JourneyLambdaOptions<Env>): JourneyLambda;
|
|
72
73
|
//#endregion
|
|
73
|
-
export { AwsEngineStep, AwsInstances, type AwsInstancesOptions, type ExecutionState, type FunctionUrlEvent, type FunctionUrlResult, type LambdaApi, RUN_EVENT_TYPE, type RunInput, executionName, fromResponse, isFunctionUrlEvent, isRunInput, sdkLambdaApi, stepRetry, toRequest };
|
|
74
|
+
export { type Authorize, AwsEngineStep, AwsInstances, type AwsInstancesOptions, type ExecutionState, type FunctionUrlEvent, type FunctionUrlResult, type LambdaApi, RUN_EVENT_TYPE, type RunInput, bearerMatches, executionName, fromResponse, isFunctionUrlEvent, isRunInput, sdkLambdaApi, stepRetry, timingSafeEqual, toRequest };
|
|
74
75
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/aws/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/aws/index.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/aws/index.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAqDa;;YAGD,mBAAmB,KAAK;iBAEnB,qBAAqB,YAAY,0BAA0B,KAC1E,mBAAmB,MAAM;;EAIzB,KAAK;;EAEL,QAAQ,KAAK,KAAK;IAAY;QAAqB,eAAe;;EAElE,SAAS;;EAET;;;;;;EAMA;;;;;;EAMA;;;;;;iBAOe;EACf;EACA;EACA;EACA;EACA;;iBAGe;;EAEf,UACE,OAAO,iCACP,SAAS,kBACN,QAAQ;;wBAeC,cAAc,YAAY,kBACxC,SAAS,qBAAqB,OAC7B"}
|
package/dist/aws/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { longestJourneyMs } from "../engine/duration.mjs";
|
|
|
6
6
|
import { readWithRetry } from "../host/fresh-read.mjs";
|
|
7
7
|
import { sweepMaintenance, sweepSegmentsPaged } from "../host/ingest.mjs";
|
|
8
8
|
import { executeJourney } from "../host/execute.mjs";
|
|
9
|
+
import { bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
9
10
|
import { createJourneyHost } from "../host/journey-host.mjs";
|
|
10
11
|
import { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from "./instances.mjs";
|
|
11
12
|
import { sdkLambdaApi } from "./lambda-api.mjs";
|
|
@@ -192,6 +193,6 @@ function journeyLambda(options) {
|
|
|
192
193
|
return { handler };
|
|
193
194
|
}
|
|
194
195
|
//#endregion
|
|
195
|
-
export { AwsEngineStep, AwsInstances, RUN_EVENT_TYPE, SWEEP_EVENT_TYPE, executionName, fromResponse, isFunctionUrlEvent, isRunInput, journeyLambda, sdkLambdaApi, stepRetry, toRequest };
|
|
196
|
+
export { AwsEngineStep, AwsInstances, RUN_EVENT_TYPE, SWEEP_EVENT_TYPE, bearerMatches, executionName, fromResponse, isFunctionUrlEvent, isRunInput, journeyLambda, sdkLambdaApi, stepRetry, timingSafeEqual, toRequest };
|
|
196
197
|
|
|
197
198
|
//# sourceMappingURL=index.mjs.map
|
package/dist/aws/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const executions = store.executions;\n if (executions === undefined) {\n throw new Error(\n 'journeyLambda needs a store with an execution registry (PostgresJourneyStore)'\n );\n }\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return (await store.reporting?.snapshotSegments(Date.now())) ?? [];\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const executions = store.executions;\n if (executions === undefined) {\n throw new Error(\n 'journeyLambda needs a store with an execution registry (PostgresJourneyStore)'\n );\n }\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return (await store.reporting?.snapshotSegments(Date.now())) ?? [];\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
|
|
@@ -64,6 +64,7 @@ exports.runtimeFromConfig = require_host_channels.runtimeFromConfig;
|
|
|
64
64
|
exports.sweepMaintenance = require_host_ingest.sweepMaintenance;
|
|
65
65
|
exports.sweepSegments = require_host_ingest.sweepSegments;
|
|
66
66
|
exports.sweepSegmentsPaged = require_host_ingest.sweepSegmentsPaged;
|
|
67
|
+
exports.timingSafeEqual = require_host_auth.timingSafeEqual;
|
|
67
68
|
exports.trackEmail = require_email_sender.trackEmail;
|
|
68
69
|
exports.unbindDevice = require_host_ingest.unbindDevice;
|
|
69
70
|
exports.wakeExpired = require_cloudflare_bindings.wakeExpired;
|
|
@@ -10,16 +10,16 @@ import { PostgresJourneyStore, PostgresJourneyStoreOptions } from "../store/post
|
|
|
10
10
|
import { PostgresReporting } from "../store/postgres-reporting.cjs";
|
|
11
11
|
import { BundleVersion, Engagement, EntryInput, EntryOutcome, EventInput, JourneyReporting, JourneyStore, MessageRecord, ProfileProps, SchemaMismatchError, SegmentTriggerRoute, StoreLease, TriggerRoute } from "../store/index.cjs";
|
|
12
12
|
import { EmailMessage, EmailRenderer, EmailSender, EmailSenderOptions, EmailTransport, RenderedEmail, trackEmail } from "../email-sender.cjs";
|
|
13
|
+
import { Authorize, authorized, bearerMatches, timingSafeEqual } from "../host/auth.cjs";
|
|
13
14
|
import { BundleIntegrityError, DeployResult, DeviceRegistration, IdentifyResult, IngestInput, IngestResult, SWEEP_PAGE_SIZE, SweepPage, SweepResult, UnversionedStoreError, deployBundle, forgetUser, identifyUser, ingestEvent, registerDevice, removeDevice, sweepMaintenance, sweepSegments, sweepSegmentsPaged, unbindDevice } from "../host/ingest.cjs";
|
|
14
15
|
import { EnvValidationError } from "../host/journey-host.cjs";
|
|
15
16
|
import { d1Store } from "./d1-env.cjs";
|
|
16
17
|
import { TrackingOptions, handleTracking, recordPushOpen } from "../reporting/tracking.cjs";
|
|
17
18
|
import { IngestValidation, handleRequest } from "../host/http.cjs";
|
|
18
|
-
import { authorized, bearerMatches } from "../host/auth.cjs";
|
|
19
19
|
import { JourneyRunner } from "./runner.cjs";
|
|
20
20
|
import { LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform } from "../host/senders.cjs";
|
|
21
21
|
import { ChannelsConfig, channelsFromConfig, journeyMode, runtimeFromConfig } from "../host/channels.cjs";
|
|
22
22
|
import { reportingObserver } from "../reporting/observer.cjs";
|
|
23
23
|
import { BURST_WINDOW_MS, ClassifyOptions, ClickFacts, DEFAULT_QUICK_MS, RequestFacts, Verdict, classifyClick, classifyOpen } from "../reporting/classify.cjs";
|
|
24
24
|
import { ExecutionContextLike, JourneyWorker, JourneyWorkerOptions, journeyWorker } from "./worker.cjs";
|
|
25
|
-
export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailMessage, type EmailRenderer, EmailSender, type EmailSenderOptions, type EmailTransport, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type IngestValidation, InstanceNotFoundError, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SWEEP_PAGE_SIZE, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepPage, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, UserErasedError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, trackEmail, unbindDevice, wakeExpired };
|
|
25
|
+
export { type Authorize, BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailMessage, type EmailRenderer, EmailSender, type EmailSenderOptions, type EmailTransport, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type IngestValidation, InstanceNotFoundError, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SWEEP_PAGE_SIZE, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepPage, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, UserErasedError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, timingSafeEqual, trackEmail, unbindDevice, wakeExpired };
|
|
@@ -10,16 +10,16 @@ import { PostgresJourneyStore, PostgresJourneyStoreOptions } from "../store/post
|
|
|
10
10
|
import { PostgresReporting } from "../store/postgres-reporting.mjs";
|
|
11
11
|
import { BundleVersion, Engagement, EntryInput, EntryOutcome, EventInput, JourneyReporting, JourneyStore, MessageRecord, ProfileProps, SchemaMismatchError, SegmentTriggerRoute, StoreLease, TriggerRoute } from "../store/index.mjs";
|
|
12
12
|
import { EmailMessage, EmailRenderer, EmailSender, EmailSenderOptions, EmailTransport, RenderedEmail, trackEmail } from "../email-sender.mjs";
|
|
13
|
+
import { Authorize, authorized, bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
13
14
|
import { BundleIntegrityError, DeployResult, DeviceRegistration, IdentifyResult, IngestInput, IngestResult, SWEEP_PAGE_SIZE, SweepPage, SweepResult, UnversionedStoreError, deployBundle, forgetUser, identifyUser, ingestEvent, registerDevice, removeDevice, sweepMaintenance, sweepSegments, sweepSegmentsPaged, unbindDevice } from "../host/ingest.mjs";
|
|
14
15
|
import { EnvValidationError } from "../host/journey-host.mjs";
|
|
15
16
|
import { d1Store } from "./d1-env.mjs";
|
|
16
17
|
import { TrackingOptions, handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
|
|
17
18
|
import { IngestValidation, handleRequest } from "../host/http.mjs";
|
|
18
|
-
import { authorized, bearerMatches } from "../host/auth.mjs";
|
|
19
19
|
import { JourneyRunner } from "./runner.mjs";
|
|
20
20
|
import { LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform } from "../host/senders.mjs";
|
|
21
21
|
import { ChannelsConfig, channelsFromConfig, journeyMode, runtimeFromConfig } from "../host/channels.mjs";
|
|
22
22
|
import { reportingObserver } from "../reporting/observer.mjs";
|
|
23
23
|
import { BURST_WINDOW_MS, ClassifyOptions, ClickFacts, DEFAULT_QUICK_MS, RequestFacts, Verdict, classifyClick, classifyOpen } from "../reporting/classify.mjs";
|
|
24
24
|
import { ExecutionContextLike, JourneyWorker, JourneyWorkerOptions, journeyWorker } from "./worker.mjs";
|
|
25
|
-
export { BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailMessage, type EmailRenderer, EmailSender, type EmailSenderOptions, type EmailTransport, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type IngestValidation, InstanceNotFoundError, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SWEEP_PAGE_SIZE, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepPage, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, UserErasedError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, trackEmail, unbindDevice, wakeExpired };
|
|
25
|
+
export { type Authorize, BURST_WINDOW_MS, BundleIntegrityError, type BundleVersion, type ChannelsConfig, type ClassifyOptions, type ClickFacts, type D1DatabaseLike, D1JourneyStore, DEFAULT_QUICK_MS, type DeployResult, type DeviceRegistration, type EmailAddress, type EmailBindingLike, type EmailMessage, type EmailRenderer, EmailSender, type EmailSenderOptions, type EmailTransport, type Engagement, type EntryInput, type EntryOutcome, EnvValidationError, type EventInput, type ExecutionContextLike, type IdentifyResult, type IngestInput, type IngestResult, type IngestValidation, InstanceNotFoundError, type JourneyEnv, JourneyFactSource, type JourneyObserver, type JourneyParams, type JourneyReporting, JourneyRunner, type JourneyStore, type JourneyWorker, type JourneyWorkerOptions, type LinkPurpose, LogMessageSender, type MessageRecord, type MessageTracking, type OutboundMessage, PostgresJourneyStore, type PostgresJourneyStoreOptions, PostgresReporting, type ProfileProps, type PushDevice, type RenderedEmail, type RequestFacts, SWEEP_PAGE_SIZE, SchemaMismatchError, type SegmentTriggerRoute, type SendResult, type SqlClientLike, type StoreLease, type SweepPage, type SweepResult, type TrackingOptions, type TriggerRoute, UnversionedStoreError, UserErasedError, type Verdict, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, type WorkflowBindingLike, type WorkflowInstanceLike, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, timingSafeEqual, trackEmail, unbindDevice, wakeExpired };
|
|
@@ -13,10 +13,10 @@ import { LogMessageSender, WebhookMessageSender, routeByChannel, routeByPlatform
|
|
|
13
13
|
import { channelsFromConfig, journeyMode, runtimeFromConfig } from "../host/channels.mjs";
|
|
14
14
|
import { BURST_WINDOW_MS, DEFAULT_QUICK_MS, classifyClick, classifyOpen } from "../reporting/classify.mjs";
|
|
15
15
|
import { handleTracking, recordPushOpen } from "../reporting/tracking.mjs";
|
|
16
|
-
import { authorized, bearerMatches } from "../host/auth.mjs";
|
|
16
|
+
import { authorized, bearerMatches, timingSafeEqual } from "../host/auth.mjs";
|
|
17
17
|
import { handleRequest } from "../host/http.mjs";
|
|
18
18
|
import { EnvValidationError } from "../host/journey-host.mjs";
|
|
19
19
|
import { WAKE_TIMEOUT_TOLERANCE_MS, cloudflareJourneyBinding, wakeExpired } from "./bindings.mjs";
|
|
20
20
|
import { JourneyRunner } from "./runner.mjs";
|
|
21
21
|
import { journeyWorker } from "./worker.mjs";
|
|
22
|
-
export { BURST_WINDOW_MS, BundleIntegrityError, D1JourneyStore, DEFAULT_QUICK_MS, EmailSender, EnvValidationError, InstanceNotFoundError, JourneyFactSource, JourneyRunner, LogMessageSender, PostgresJourneyStore, PostgresReporting, SWEEP_PAGE_SIZE, SchemaMismatchError, UnversionedStoreError, UserErasedError, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, trackEmail, unbindDevice, wakeExpired };
|
|
22
|
+
export { BURST_WINDOW_MS, BundleIntegrityError, D1JourneyStore, DEFAULT_QUICK_MS, EmailSender, EnvValidationError, InstanceNotFoundError, JourneyFactSource, JourneyRunner, LogMessageSender, PostgresJourneyStore, PostgresReporting, SWEEP_PAGE_SIZE, SchemaMismatchError, UnversionedStoreError, UserErasedError, WAKE_EVENT_TYPE, WAKE_TIMEOUT_TOLERANCE_MS, WebhookMessageSender, authorized, bearerMatches, channelsFromConfig, classifyClick, classifyOpen, cloudflareJourneyBinding, d1Store, deployBundle, forgetUser, handleRequest, handleTracking, identifyUser, ingestEvent, journeyMode, journeyWorker, recordPushOpen, registerDevice, removeDevice, reportingObserver, routeByChannel, routeByPlatform, runtimeFromConfig, sweepMaintenance, sweepSegments, sweepSegmentsPaged, timingSafeEqual, trackEmail, unbindDevice, wakeExpired };
|
package/dist/fcm.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_engine_retry = require("./engine/retry.cjs");
|
|
3
3
|
const require_jwt = require("./jwt.cjs");
|
|
4
|
+
let node_buffer = require("node:buffer");
|
|
4
5
|
//#region src/fcm.ts
|
|
5
6
|
/**
|
|
6
7
|
* How an FCM v1 answer is classified, after the API's error codes
|
|
@@ -180,7 +181,7 @@ async function signJwt(account, audience, scope, nowMs = Date.now()) {
|
|
|
180
181
|
function serviceAccountFromBase64(value) {
|
|
181
182
|
let json;
|
|
182
183
|
try {
|
|
183
|
-
json = JSON.parse(
|
|
184
|
+
json = JSON.parse(node_buffer.Buffer.from(value, "base64").toString());
|
|
184
185
|
} catch (error) {
|
|
185
186
|
throw new require_engine_retry.PermanentError("service account is not base64-encoded JSON", { cause: error });
|
|
186
187
|
}
|
package/dist/fcm.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.cjs","names":["isRetryableStatus","PermanentError","base64url","pemToDer"],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fcm.cjs","names":["isRetryableStatus","PermanentError","base64url","pemToDer","Buffer"],"sources":["../src/fcm.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(Buffer.from(value, 'base64').toString()) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|
package/dist/fcm.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.d.cts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"fcm.d.cts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAiBiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;;EAEhB,eAAe;;iBAGA;;EAEf,aAAa;EACb,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;;;;;;;;;;;;;;;;;;;qBAqBJ,oBAAoB;;;;;;;qBAgBpB;mBAMkB;;WAJpB;UACD;UACA;EAER,YAA6B,SAAS;;EAKhC,MAAM,eAAmB;;EAgB/B;UAIc;;qBA6BH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAyEvC,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
|
package/dist/fcm.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.d.mts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"fcm.d.mts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAiBiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;;EAEhB,eAAe;;iBAGA;;EAEf,aAAa;EACb,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;;;;;;;;;;;;;;;;;;;qBAqBJ,oBAAoB;;;;;;;qBAgBpB;mBAMkB;;WAJpB;UACD;UACA;EAER,YAA6B,SAAS;;EAKhC,MAAM,eAAmB;;EAgB/B;UAIc;;qBA6BH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAyEvC,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
|
package/dist/fcm.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
|
|
2
2
|
import { base64url, pemToDer } from "./jwt.mjs";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
3
4
|
//#region src/fcm.ts
|
|
4
5
|
/**
|
|
5
6
|
* How an FCM v1 answer is classified, after the API's error codes
|
|
@@ -179,7 +180,7 @@ async function signJwt(account, audience, scope, nowMs = Date.now()) {
|
|
|
179
180
|
function serviceAccountFromBase64(value) {
|
|
180
181
|
let json;
|
|
181
182
|
try {
|
|
182
|
-
json = JSON.parse(
|
|
183
|
+
json = JSON.parse(Buffer.from(value, "base64").toString());
|
|
183
184
|
} catch (error) {
|
|
184
185
|
throw new PermanentError("service account is not base64-encoded JSON", { cause: error });
|
|
185
186
|
}
|
package/dist/fcm.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.mjs","names":[],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fcm.mjs","names":[],"sources":["../src/fcm.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(Buffer.from(value, 'base64').toString()) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
|