typed-tasks
Version:
A type-safe abstraction for Google Cloud Tasks
1 lines • 20.8 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","names":["scheduleTimeSeconds: number | undefined","task: {\n name?: string;\n httpRequest: {\n httpMethod: \"POST\";\n url: string;\n oidcToken: { serviceAccountEmail: string };\n headers: { \"content-type\": string };\n body: string;\n };\n scheduleTime?: { seconds: number };\n }","globalHandlerOptions: TaskHandlerOptions"],"sources":["../src/constants.ts","../src/handler.ts","../src/scheduler.ts","../src/task-registry.ts","../src/factory.ts"],"sourcesContent":["export const MINUTE_SECONDS = 60;\nexport const HOUR_SECONDS = 60 * MINUTE_SECONDS;\nexport const DAY_SECONDS = 24 * HOUR_SECONDS;\n\n/** Default options for Task handlers */\nexport const defaultHandlerOptions = {\n memory: \"512MiB\",\n timeoutSeconds: 30 * MINUTE_SECONDS, // 30 minutes (maximum allowed)\n vpcConnector: undefined,\n cpu: 1,\n\n // Queue congestion control settings\n rateLimits: {\n maxDispatchesPerSecond: 500,\n maxConcurrentDispatches: 1000,\n },\n\n // Retry configuration for failed tasks\n retryConfig: {\n maxAttempts: 10,\n minBackoffSeconds: 10,\n maxBackoffSeconds: HOUR_SECONDS, // 1 hour\n maxRetrySeconds: 0, // unlimited\n },\n} as const;\n","import { onTaskDispatched } from \"firebase-functions/tasks\";\nimport { got } from \"get-or-throw\";\nimport { z } from \"zod\";\nimport { defaultHandlerOptions } from \"./constants\";\nimport type { SchemaRecord, TaskHandlerOptions } from \"./types\";\n\n/**\n * Creates a factory function for generating type-safe task handlers\n *\n * @param schemas - Extracted schemas from task definitions\n * @param region - GCP region\n * @param globalOptions - Default options for all handlers\n * @param taskRegistry - Registry to store task configurations that need to be\n * shared with the scheduler\n * @returns A factory function for creating handlers that returns an object with\n * the queueName as the property name and the handler function as the value\n */\nexport function createTaskHandlerFactory<Schemas extends SchemaRecord>(\n schemas: Schemas,\n region: string,\n globalOptions: TaskHandlerOptions = defaultHandlerOptions,\n) {\n return <T extends keyof Schemas & string>({\n queueName,\n options = {},\n handler,\n }: {\n queueName: T;\n options?: TaskHandlerOptions;\n handler: (payload: z.infer<Schemas[T]>) => Promise<void>;\n }) => {\n /**\n * Merge the default options with the globally configured options and the\n * options passed directly to the handler\n */\n const mergedOptions = {\n ...defaultHandlerOptions,\n ...globalOptions,\n ...options,\n rateLimits: {\n ...defaultHandlerOptions.rateLimits,\n ...globalOptions.rateLimits,\n ...options.rateLimits,\n },\n retryConfig: {\n ...defaultHandlerOptions.retryConfig,\n ...globalOptions.retryConfig,\n ...options.retryConfig,\n },\n };\n\n const taskHandler = onTaskDispatched(\n {\n ...mergedOptions,\n region,\n },\n async ({ data }) => {\n // Get the schema for this task\n const schema = got(schemas, queueName);\n\n const result = schema.safeParse(data);\n\n if (!result.success) {\n console.error(\n new Error(`Zod validation error for queue ${queueName}`),\n result.error.flatten(),\n );\n // If validation fails, don't retry because it won't succeed\n return;\n }\n\n // The result.data is now statically typed by zod as the correct type\n // since we successfully validated it with the schema\n return handler(result.data);\n },\n );\n\n // Return the handler function directly for easier exports\n return taskHandler;\n };\n}\n","import type { CloudTasksClient } from \"@google-cloud/tasks\";\nimport crypto from \"node:crypto\";\nimport pRetry, { AbortError } from \"p-retry\";\nimport type { z } from \"zod\";\nimport type { TaskRegistry } from \"./task-registry\";\nimport type { ExtractSchema, TaskDefinitionRecord } from \"./types\";\n\n/**\n * Generates a deterministic task name from payload data using MD5 hash When\n * deduplication window is set, includes a time window boundary suffix to\n * prevent collisions across different time windows\n *\n * @param data - The payload data to hash\n * @param deduplicationWindowSeconds - Optional deduplication window in seconds\n * @returns A string containing the MD5 hash of the stringified data, with\n * optional time window suffix\n */\nfunction generateTaskNameFromPayload(\n data: unknown,\n deduplicationWindowSeconds?: number,\n): string {\n const dataString = typeof data === \"string\" ? data : JSON.stringify(data);\n const baseHash = crypto.createHash(\"md5\").update(dataString).digest(\"hex\");\n\n // If we have a deduplication window, add a time window suffix\n if (deduplicationWindowSeconds && deduplicationWindowSeconds > 0) {\n // Round the current timestamp to the nearest window boundary\n const currentTime = Date.now();\n const windowBoundary = Math.floor(\n currentTime / (deduplicationWindowSeconds * 1000),\n );\n return `${baseHash}-${windowBoundary}`;\n }\n\n return baseHash;\n}\n\nfunction getErrorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * Creates a factory function that produces type-safe task schedulers for\n * specific tasks\n *\n * @param tasksClient - Google Cloud Tasks client\n * @param projectId - Google Cloud project ID\n * @param region - GCP region for the Cloud Tasks\n * @param taskRegistry - Registry containing task configurations from task\n * definitions\n * @returns A factory function for creating task schedulers\n */\nexport function createSchedulerFactory<\n Defs extends TaskDefinitionRecord<string>,\n>(\n tasksClient: CloudTasksClient,\n projectId: string,\n region: string,\n taskRegistry: TaskRegistry,\n) {\n return <T extends keyof Defs & string>(queueName: T) => {\n /**\n * Schedules a task to be executed\n *\n * @param data - The data to schedule as a task, must conform to the task's\n * schema\n * @param options - Optional configuration options including taskName for\n * deduplication and delaySeconds for custom delays\n * @returns Promise that resolves when the task is scheduled\n */\n return async (\n data: z.infer<ExtractSchema<Defs[T]>>,\n options?: { taskName?: string; delaySeconds?: number },\n ): Promise<void> => {\n const taskConfig = taskRegistry.get(queueName);\n const deduplicationWindowSeconds = taskConfig?.deduplicationWindowSeconds;\n\n const targetRegion = region;\n\n // Get the parent queue path\n const parent = tasksClient.queuePath(projectId, targetRegion, queueName);\n\n const serviceAccountEmail = `${projectId}@appspot.gserviceaccount.com`;\n\n let scheduleTimeSeconds: number | undefined;\n\n const useDeduplication =\n !!taskConfig?.useDeduplication ||\n (!!deduplicationWindowSeconds && deduplicationWindowSeconds > 0);\n\n /** Generate a task name if needed or add time window suffix */\n let finalTaskName = options?.taskName;\n if (useDeduplication && !finalTaskName) {\n // No taskName provided, generate one with window suffix if needed\n finalTaskName = generateTaskNameFromPayload(\n data,\n deduplicationWindowSeconds,\n );\n } else if (\n finalTaskName &&\n deduplicationWindowSeconds &&\n deduplicationWindowSeconds > 0\n ) {\n // TaskName was provided, but we need to add time window suffix\n const currentTime = Date.now();\n const windowBoundary = Math.floor(\n currentTime / (deduplicationWindowSeconds * 1000),\n );\n finalTaskName = `${finalTaskName}-${windowBoundary}`;\n }\n\n try {\n /**\n * Priority: deduplicationWindowSeconds > delaySeconds If a\n * deduplication window is configured, use that delay Otherwise, use\n * delaySeconds if provided\n */\n if (deduplicationWindowSeconds && deduplicationWindowSeconds > 0) {\n scheduleTimeSeconds =\n Math.floor(Date.now() / 1000) + deduplicationWindowSeconds;\n } else if (options?.delaySeconds && options.delaySeconds > 0) {\n scheduleTimeSeconds =\n Math.floor(Date.now() / 1000) + options.delaySeconds;\n }\n\n /**\n * The body HAS to contain the payload in the \"data\" key for the cloud\n * functions onTaskDispatched to accept/parse the body. It also needs to\n * be encoded with base64.\n */\n const body = Buffer.from(JSON.stringify({ data })).toString(\"base64\");\n\n const task: {\n name?: string;\n httpRequest: {\n httpMethod: \"POST\";\n url: string;\n oidcToken: { serviceAccountEmail: string };\n headers: { \"content-type\": string };\n body: string;\n };\n scheduleTime?: { seconds: number };\n } = {\n httpRequest: {\n httpMethod: \"POST\",\n url: `https://${targetRegion}-${projectId}.cloudfunctions.net/${queueName}`,\n oidcToken: {\n serviceAccountEmail,\n },\n headers: {\n \"content-type\": \"application/json\",\n },\n body,\n },\n };\n\n /**\n * Set the task name property if we have a taskName (either provided or\n * generated)\n */\n if (finalTaskName) {\n task.name = tasksClient.taskPath(\n projectId,\n targetRegion,\n queueName,\n finalTaskName,\n );\n }\n\n /** Set schedule time if delay is configured */\n if (scheduleTimeSeconds) {\n task.scheduleTime = {\n seconds: scheduleTimeSeconds,\n };\n }\n\n /**\n * Use p-retry to handle transient failures when creating tasks with\n * exponential backoff and jitter\n */\n await pRetry(\n async () => {\n try {\n return await tasksClient.createTask({ parent, task });\n } catch (error) {\n // If task already exists, abort retry (part of deduplication)\n if (\n error instanceof Error &&\n error.message.includes(\"ALREADY_EXISTS\")\n ) {\n throw new AbortError(error.message);\n }\n throw error; // Let other errors be retried\n }\n },\n {\n retries: 5, // Maximum number of retry attempts\n factor: 2, // Exponential backoff factor\n minTimeout: 1000, // Initial retry delay (1 second)\n maxTimeout: 10000, // Maximum retry delay (10 seconds)\n randomize: true, // Add jitter to prevent thundering herd\n onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => {\n // Only log if not aborted due to ALREADY_EXISTS\n if (!(error instanceof AbortError)) {\n console.warn(\n `Task scheduling attempt ${attemptNumber} failed for ${queueName}. ${retriesLeft} retries left.`,\n getErrorMessage(error),\n );\n }\n },\n },\n );\n } catch (error) {\n if (\n error instanceof Error &&\n error.message.includes(\"ALREADY_EXISTS\")\n ) {\n // Task already exists, which is expected with deduplication\n console.info(`Skipping task ${finalTaskName}`, { data });\n return;\n }\n\n // For other errors, log and rethrow\n const errorMessage = getErrorMessage(error);\n console.error(\n new Error(\n `Failed to schedule task ${queueName} after multiple retries: ${errorMessage}`,\n ),\n );\n throw error;\n }\n };\n };\n}\n","import type { TaskSchedulerOptions } from \"./types\";\n\n/** Configuration for tasks that only contains scheduler options */\nexport type TaskConfig = TaskSchedulerOptions;\n\n/**\n * Creates a new queue registry instance\n *\n * This registry maps queue names to their task configurations which includes\n * deduplication settings\n */\nexport function createTaskRegistry() {\n return new Map<string, TaskConfig>();\n}\n\n/** Type definition for a task registry */\nexport type TaskRegistry = ReturnType<typeof createTaskRegistry>;\n","import type { CloudTasksClient } from \"@google-cloud/tasks\";\nimport { z } from \"zod\";\nimport { defaultHandlerOptions } from \"./constants\";\nimport { createTaskHandlerFactory } from \"./handler\";\nimport { createSchedulerFactory } from \"./scheduler\";\nimport { createTaskRegistry } from \"./task-registry\";\nimport type {\n SchemaRecord,\n TaskDefinitionRecord,\n TaskHandlerOptions,\n TaskSchedulerOptions,\n TypedTasksClient,\n} from \"./types\";\n\n/**\n * Utility to check if a task definition is a direct schema or an object with\n * schema\n */\nfunction isSchemaDefinition(\n definition: z.ZodType | { schema: z.ZodType; options?: TaskSchedulerOptions },\n): definition is z.ZodType {\n return typeof definition === \"function\" || \"parse\" in definition;\n}\n\n/**\n * Creates a type-safe Tasks client for handling and scheduling tasks with\n * schema validation\n *\n * @param options - Options object containing client configuration\n * @param options.tasksClient - Google Cloud Tasks client instance\n * @param options.taskDefinitions - Object containing schema and options for\n * each task\n * @param options.projectId - GCP project ID\n * @param options.region - GCP region for the Cloud Tasks\n * @param options.options - Optional configuration options for all tasks\n * @returns Type-safe Tasks client with scheduler and handler factories\n */\nexport function createTypedTasks<\n TaskDefs extends TaskDefinitionRecord<string>,\n>({\n client,\n definitions,\n projectId,\n region,\n options = {},\n}: {\n client: CloudTasksClient;\n definitions: TaskDefs;\n projectId: string;\n region: string;\n options?: TaskHandlerOptions;\n}): TypedTasksClient<TaskDefs> {\n // Merge default handler options with options passed to createTypedTasks\n const globalHandlerOptions: TaskHandlerOptions = {\n ...defaultHandlerOptions,\n ...options,\n rateLimits: {\n ...defaultHandlerOptions.rateLimits,\n ...options.rateLimits,\n },\n retryConfig: {\n ...defaultHandlerOptions.retryConfig,\n ...options.retryConfig,\n },\n };\n\n // Create a task registry for this instance\n const taskRegistry = createTaskRegistry();\n\n // Extract schemas from taskDefinitions for schema validation\n const schemas = Object.fromEntries(\n Object.entries(definitions).map(([key, value]) => {\n // Check if we're dealing with a direct schema or an object with schema\n if (isSchemaDefinition(value)) {\n return [key, value];\n } else {\n return [key, value.schema];\n }\n }),\n ) as SchemaRecord;\n\n // Populate task registry with scheduler options from taskDefinitions\n Object.entries(definitions).forEach(([queueName, definition]) => {\n // Only object with schema+options will have scheduler options\n if (!isSchemaDefinition(definition) && definition.options) {\n const deduplicationWindowSeconds =\n definition.options.deduplicationWindowSeconds;\n const useDeduplication =\n !!definition.options.useDeduplication ||\n (!!deduplicationWindowSeconds && deduplicationWindowSeconds > 0);\n\n taskRegistry.set(queueName, {\n deduplicationWindowSeconds,\n useDeduplication,\n });\n }\n });\n\n // Get createScheduler factory function\n const schedulerFactory = createSchedulerFactory<TaskDefs>(\n client,\n projectId,\n region,\n taskRegistry,\n );\n\n // Get createHandler factory function\n const handlerFactory = createTaskHandlerFactory(\n schemas,\n region,\n globalHandlerOptions,\n );\n\n // Create a proxy to handle direct access to task names\n const tasksProxy = {\n createScheduler: schedulerFactory,\n createHandler: <T extends keyof TaskDefs & string>(config: {\n queueName: T;\n options?: TaskHandlerOptions;\n handler: (payload: z.infer<(typeof schemas)[T]>) => Promise<void>;\n }) => {\n return handlerFactory(config);\n },\n } as TypedTasksClient<TaskDefs> & Record<string, unknown>;\n\n // Add each queue name as a property to allow checking with \"in\" operator\n Object.keys(definitions).forEach((queueName) => {\n tasksProxy[queueName] = true;\n });\n\n return tasksProxy;\n}\n"],"mappings":"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"}