chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# Core Package
|
||||
|
||||
`@trigger.dev/core` - shared types, schemas, and utilities used across the SDK, CLI, and webapp.
|
||||
|
||||
## Critical Import Rule
|
||||
|
||||
**NEVER import the root** (`@trigger.dev/core`). Always use subpath imports:
|
||||
|
||||
```typescript
|
||||
import { ... } from "@trigger.dev/core/v3";
|
||||
import { ... } from "@trigger.dev/core/v3/utils";
|
||||
import { ... } from "@trigger.dev/core/logger";
|
||||
import { ... } from "@trigger.dev/core/schemas";
|
||||
```
|
||||
|
||||
## Cross-Cutting Impact
|
||||
|
||||
Changes here affect both the customer-facing SDK and the server-side webapp. Exercise caution - breaking changes can affect deployed user tasks and the platform simultaneously.
|
||||
|
||||
## Contents
|
||||
|
||||
- Protocol definitions and message types
|
||||
- API schemas (Zod validation)
|
||||
- Shared constants and enums
|
||||
- Utility functions used across packages
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Trigger.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,667 @@
|
||||
{
|
||||
"name": "@trigger.dev/core",
|
||||
"version": "4.5.3",
|
||||
"description": "Core code used across the Trigger.dev SDK and platform",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"tshy": {
|
||||
"selfLink": false,
|
||||
"main": true,
|
||||
"module": true,
|
||||
"project": "./tsconfig.src.json",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/index.ts",
|
||||
"./logger": "./src/logger.ts",
|
||||
"./utils": "./src/utils.ts",
|
||||
"./schemas": "./src/schemas/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./v3": "./src/v3/index.ts",
|
||||
"./v3/tracer": "./src/v3/tracer.ts",
|
||||
"./v3/build": "./src/v3/build/index.ts",
|
||||
"./v3/apps": "./src/v3/apps/index.ts",
|
||||
"./v3/auth/environment": "./src/v3/auth/environment.ts",
|
||||
"./v3/jwt": "./src/v3/jwt.ts",
|
||||
"./v3/errors": "./src/v3/errors.ts",
|
||||
"./v3/logger-api": "./src/v3/logger-api.ts",
|
||||
"./v3/otel": "./src/v3/otel/index.ts",
|
||||
"./v3/semanticInternalAttributes": "./src/v3/semanticInternalAttributes.ts",
|
||||
"./v3/utils/durations": "./src/v3/utils/durations.ts",
|
||||
"./v3/utils/flattenAttributes": "./src/v3/utils/flattenAttributes.ts",
|
||||
"./v3/utils/gitBranch": "./src/v3/utils/gitBranch.ts",
|
||||
"./v3/utils/ioSerialization": "./src/v3/utils/ioSerialization.ts",
|
||||
"./v3/utils/omit": "./src/v3/utils/omit.ts",
|
||||
"./v3/utils/retries": "./src/v3/utils/retries.ts",
|
||||
"./v3/utils/structuredLogger": "./src/v3/utils/structuredLogger.ts",
|
||||
"./v3/telnetLogServer": "./src/v3/telnetLogServer.ts",
|
||||
"./v3/test": "./src/v3/test/index.ts",
|
||||
"./v3/zodfetch": "./src/v3/zodfetch.ts",
|
||||
"./v3/zodMessageHandler": "./src/v3/zodMessageHandler.ts",
|
||||
"./v3/zodNamespace": "./src/v3/zodNamespace.ts",
|
||||
"./v3/zodSocket": "./src/v3/zodSocket.ts",
|
||||
"./v3/zodIpc": "./src/v3/zodIpc.ts",
|
||||
"./v3/utils/timers": "./src/v3/utils/timers.ts",
|
||||
"./v3/workers": "./src/v3/workers/index.ts",
|
||||
"./v3/schemas": "./src/v3/schemas/index.ts",
|
||||
"./v3/runEngineWorker": "./src/v3/runEngineWorker/index.ts",
|
||||
"./v3/machines": "./src/v3/machines/index.ts",
|
||||
"./v3/serverOnly": "./src/v3/serverOnly/index.ts",
|
||||
"./v3/isomorphic": "./src/v3/isomorphic/index.ts",
|
||||
"./v3/sdk-scope-storage": "./src/v3/sdkScope/storage-node.ts"
|
||||
},
|
||||
"sourceDialects": [
|
||||
"@triggerdotdev/source"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"logger": [
|
||||
"dist/commonjs/logger.d.ts"
|
||||
],
|
||||
"utils": [
|
||||
"dist/commonjs/utils.d.ts"
|
||||
],
|
||||
"schemas": [
|
||||
"dist/commonjs/schemas/index.d.ts"
|
||||
],
|
||||
"types": [
|
||||
"dist/commonjs/types.d.ts"
|
||||
],
|
||||
"v3": [
|
||||
"dist/commonjs/v3/index.d.ts"
|
||||
],
|
||||
"v3/tracer": [
|
||||
"dist/commonjs/v3/tracer.d.ts"
|
||||
],
|
||||
"v3/build": [
|
||||
"dist/commonjs/v3/build/index.d.ts"
|
||||
],
|
||||
"v3/apps": [
|
||||
"dist/commonjs/v3/apps/index.d.ts"
|
||||
],
|
||||
"v3/errors": [
|
||||
"dist/commonjs/v3/errors.d.ts"
|
||||
],
|
||||
"v3/logger-api": [
|
||||
"dist/commonjs/v3/logger-api.d.ts"
|
||||
],
|
||||
"v3/otel": [
|
||||
"dist/commonjs/v3/otel/index.d.ts"
|
||||
],
|
||||
"v3/semanticInternalAttributes": [
|
||||
"dist/commonjs/v3/semanticInternalAttributes.d.ts"
|
||||
],
|
||||
"v3/auth/environment": [
|
||||
"dist/commonjs/v3/auth/environment.d.ts"
|
||||
],
|
||||
"v3/utils/durations": [
|
||||
"dist/commonjs/v3/utils/durations.d.ts"
|
||||
],
|
||||
"v3/utils/flattenAttributes": [
|
||||
"dist/commonjs/v3/utils/flattenAttributes.d.ts"
|
||||
],
|
||||
"v3/utils/gitBranch": [
|
||||
"dist/commonjs/v3/utils/gitBranch.d.ts"
|
||||
],
|
||||
"v3/utils/ioSerialization": [
|
||||
"dist/commonjs/v3/utils/ioSerialization.d.ts"
|
||||
],
|
||||
"v3/utils/omit": [
|
||||
"dist/commonjs/v3/utils/omit.d.ts"
|
||||
],
|
||||
"v3/utils/retries": [
|
||||
"dist/commonjs/v3/utils/retries.d.ts"
|
||||
],
|
||||
"v3/utils/structuredLogger": [
|
||||
"dist/commonjs/v3/utils/structuredLogger.d.ts"
|
||||
],
|
||||
"v3/telnetLogServer": [
|
||||
"dist/commonjs/v3/telnetLogServer.d.ts"
|
||||
],
|
||||
"v3/zodfetch": [
|
||||
"dist/commonjs/v3/zodfetch.d.ts"
|
||||
],
|
||||
"v3/zodMessageHandler": [
|
||||
"dist/commonjs/v3/zodMessageHandler.d.ts"
|
||||
],
|
||||
"v3/zodNamespace": [
|
||||
"dist/commonjs/v3/zodNamespace.d.ts"
|
||||
],
|
||||
"v3/zodSocket": [
|
||||
"dist/commonjs/v3/zodSocket.d.ts"
|
||||
],
|
||||
"v3/zodIpc": [
|
||||
"dist/commonjs/v3/zodIpc.d.ts"
|
||||
],
|
||||
"v3/utils/timers": [
|
||||
"dist/commonjs/v3/utils/timers.d.ts"
|
||||
],
|
||||
"v3/workers": [
|
||||
"dist/commonjs/v3/workers/index.d.ts"
|
||||
],
|
||||
"v3/schemas": [
|
||||
"dist/commonjs/v3/schemas/index.d.ts"
|
||||
],
|
||||
"v3/jwt": [
|
||||
"dist/commonjs/v3/jwt.d.ts"
|
||||
],
|
||||
"v3/runEngineWorker": [
|
||||
"dist/commonjs/v3/runEngineWorker/index.d.ts"
|
||||
],
|
||||
"v3/machines": [
|
||||
"dist/commonjs/v3/machines/index.d.ts"
|
||||
],
|
||||
"v3/serverOnly": [
|
||||
"dist/commonjs/v3/serverOnly/index.d.ts"
|
||||
],
|
||||
"v3/isomorphic": [
|
||||
"dist/commonjs/v3/isomorphic/index.d.ts"
|
||||
],
|
||||
"v3/sdk-scope-storage": [
|
||||
"dist/commonjs/v3/sdkScope/storage-node.d.ts"
|
||||
],
|
||||
"v3/test": [
|
||||
"dist/commonjs/v3/test/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sideEffects": [
|
||||
"./dist/esm/v3/sdkScope/storage-node.js",
|
||||
"./dist/commonjs/v3/sdkScope/storage-node.js",
|
||||
"./src/v3/sdkScope/storage-node.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .tshy .tshy-build .turbo src/v3/vendor",
|
||||
"update-version": "tsx ../../scripts/updateVersion.ts",
|
||||
"bundle-vendor": "node scripts/bundle-superjson.mjs",
|
||||
"build": "pnpm run bundle-vendor && tshy && node scripts/bundle-superjson.mjs --copy && pnpm run update-version",
|
||||
"dev": "pnpm run bundle-vendor && tshy --watch",
|
||||
"typecheck": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.src.json",
|
||||
"typecheck:ai-v7": "pnpm run bundle-vendor && tsc --noEmit -p tsconfig.ai-v7.json",
|
||||
"pretest": "pnpm run bundle-vendor",
|
||||
"test": "vitest",
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@bugsnag/cuid": "^3.1.1",
|
||||
"@electric-sql/client": "1.0.14",
|
||||
"@google-cloud/precise-date": "^4.0.0",
|
||||
"@jsonhero/path": "^1.0.21",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.218.0",
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/host-metrics": "^0.38.3",
|
||||
"@opentelemetry/instrumentation": "0.218.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-logs": "0.218.0",
|
||||
"@opentelemetry/sdk-metrics": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.41.1",
|
||||
"@s2-dev/streamstore": "0.22.10",
|
||||
"dequal": "^2.0.3",
|
||||
"eventsource": "^3.0.5",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"execa": "^8.0.1",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"jose": "^5.4.0",
|
||||
"nanoid": "3.3.8",
|
||||
"prom-client": "^15.1.0",
|
||||
"socket.io": "4.7.4",
|
||||
"socket.io-client": "4.7.5",
|
||||
"std-env": "^3.8.1",
|
||||
"tinyexec": "^0.3.2",
|
||||
"uncrypto": "^0.1.3",
|
||||
"zod": "3.25.76",
|
||||
"zod-error": "1.5.0",
|
||||
"zod-validation-error": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/provider-utils": "^1.0.22",
|
||||
"@arethetypeswrong/cli": "^0.15.4",
|
||||
"@epic-web/test-server": "^0.1.0",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@types/humanize-duration": "^3.27.1",
|
||||
"@types/lodash.get": "^4.4.9",
|
||||
"@types/readable-stream": "^4.0.14",
|
||||
"ai": "^6.0.0",
|
||||
"ai-v7": "npm:ai@7.0.0-canary.159",
|
||||
"defu": "^6.1.4",
|
||||
"esbuild": "^0.23.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"superjson": "^2.2.1",
|
||||
"ts-essentials": "10.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
"tsx": "4.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/index.ts",
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"default": "./dist/commonjs/index.js"
|
||||
}
|
||||
},
|
||||
"./logger": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/logger.ts",
|
||||
"types": "./dist/esm/logger.d.ts",
|
||||
"default": "./dist/esm/logger.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/logger.d.ts",
|
||||
"default": "./dist/commonjs/logger.js"
|
||||
}
|
||||
},
|
||||
"./utils": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/utils.ts",
|
||||
"types": "./dist/esm/utils.d.ts",
|
||||
"default": "./dist/esm/utils.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/utils.d.ts",
|
||||
"default": "./dist/commonjs/utils.js"
|
||||
}
|
||||
},
|
||||
"./schemas": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/schemas/index.ts",
|
||||
"types": "./dist/esm/schemas/index.d.ts",
|
||||
"default": "./dist/esm/schemas/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/schemas/index.d.ts",
|
||||
"default": "./dist/commonjs/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/types.ts",
|
||||
"types": "./dist/esm/types.d.ts",
|
||||
"default": "./dist/esm/types.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/types.d.ts",
|
||||
"default": "./dist/commonjs/types.js"
|
||||
}
|
||||
},
|
||||
"./v3": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/index.ts",
|
||||
"types": "./dist/esm/v3/index.d.ts",
|
||||
"default": "./dist/esm/v3/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/tracer": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/tracer.ts",
|
||||
"types": "./dist/esm/v3/tracer.d.ts",
|
||||
"default": "./dist/esm/v3/tracer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/tracer.d.ts",
|
||||
"default": "./dist/commonjs/v3/tracer.js"
|
||||
}
|
||||
},
|
||||
"./v3/build": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/build/index.ts",
|
||||
"types": "./dist/esm/v3/build/index.d.ts",
|
||||
"default": "./dist/esm/v3/build/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/build/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/build/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/apps": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/apps/index.ts",
|
||||
"types": "./dist/esm/v3/apps/index.d.ts",
|
||||
"default": "./dist/esm/v3/apps/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/apps/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/apps/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/auth/environment": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/auth/environment.ts",
|
||||
"types": "./dist/esm/v3/auth/environment.d.ts",
|
||||
"default": "./dist/esm/v3/auth/environment.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/auth/environment.d.ts",
|
||||
"default": "./dist/commonjs/v3/auth/environment.js"
|
||||
}
|
||||
},
|
||||
"./v3/jwt": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/jwt.ts",
|
||||
"types": "./dist/esm/v3/jwt.d.ts",
|
||||
"default": "./dist/esm/v3/jwt.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/jwt.d.ts",
|
||||
"default": "./dist/commonjs/v3/jwt.js"
|
||||
}
|
||||
},
|
||||
"./v3/errors": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/errors.ts",
|
||||
"types": "./dist/esm/v3/errors.d.ts",
|
||||
"default": "./dist/esm/v3/errors.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/errors.d.ts",
|
||||
"default": "./dist/commonjs/v3/errors.js"
|
||||
}
|
||||
},
|
||||
"./v3/logger-api": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/logger-api.ts",
|
||||
"types": "./dist/esm/v3/logger-api.d.ts",
|
||||
"default": "./dist/esm/v3/logger-api.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/logger-api.d.ts",
|
||||
"default": "./dist/commonjs/v3/logger-api.js"
|
||||
}
|
||||
},
|
||||
"./v3/otel": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/otel/index.ts",
|
||||
"types": "./dist/esm/v3/otel/index.d.ts",
|
||||
"default": "./dist/esm/v3/otel/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/otel/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/otel/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/semanticInternalAttributes": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/semanticInternalAttributes.ts",
|
||||
"types": "./dist/esm/v3/semanticInternalAttributes.d.ts",
|
||||
"default": "./dist/esm/v3/semanticInternalAttributes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/semanticInternalAttributes.d.ts",
|
||||
"default": "./dist/commonjs/v3/semanticInternalAttributes.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/durations": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/durations.ts",
|
||||
"types": "./dist/esm/v3/utils/durations.d.ts",
|
||||
"default": "./dist/esm/v3/utils/durations.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/durations.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/durations.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/flattenAttributes": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/flattenAttributes.ts",
|
||||
"types": "./dist/esm/v3/utils/flattenAttributes.d.ts",
|
||||
"default": "./dist/esm/v3/utils/flattenAttributes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/flattenAttributes.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/flattenAttributes.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/gitBranch": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/gitBranch.ts",
|
||||
"types": "./dist/esm/v3/utils/gitBranch.d.ts",
|
||||
"default": "./dist/esm/v3/utils/gitBranch.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/gitBranch.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/gitBranch.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/ioSerialization": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/ioSerialization.ts",
|
||||
"types": "./dist/esm/v3/utils/ioSerialization.d.ts",
|
||||
"default": "./dist/esm/v3/utils/ioSerialization.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/ioSerialization.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/ioSerialization.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/omit": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/omit.ts",
|
||||
"types": "./dist/esm/v3/utils/omit.d.ts",
|
||||
"default": "./dist/esm/v3/utils/omit.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/omit.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/omit.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/retries": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/retries.ts",
|
||||
"types": "./dist/esm/v3/utils/retries.d.ts",
|
||||
"default": "./dist/esm/v3/utils/retries.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/retries.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/retries.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/structuredLogger": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/structuredLogger.ts",
|
||||
"types": "./dist/esm/v3/utils/structuredLogger.d.ts",
|
||||
"default": "./dist/esm/v3/utils/structuredLogger.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/structuredLogger.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/structuredLogger.js"
|
||||
}
|
||||
},
|
||||
"./v3/telnetLogServer": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/telnetLogServer.ts",
|
||||
"types": "./dist/esm/v3/telnetLogServer.d.ts",
|
||||
"default": "./dist/esm/v3/telnetLogServer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/telnetLogServer.d.ts",
|
||||
"default": "./dist/commonjs/v3/telnetLogServer.js"
|
||||
}
|
||||
},
|
||||
"./v3/test": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/test/index.ts",
|
||||
"types": "./dist/esm/v3/test/index.d.ts",
|
||||
"default": "./dist/esm/v3/test/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/test/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/test/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/zodfetch": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodfetch.ts",
|
||||
"types": "./dist/esm/v3/zodfetch.d.ts",
|
||||
"default": "./dist/esm/v3/zodfetch.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/zodfetch.d.ts",
|
||||
"default": "./dist/commonjs/v3/zodfetch.js"
|
||||
}
|
||||
},
|
||||
"./v3/zodMessageHandler": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodMessageHandler.ts",
|
||||
"types": "./dist/esm/v3/zodMessageHandler.d.ts",
|
||||
"default": "./dist/esm/v3/zodMessageHandler.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/zodMessageHandler.d.ts",
|
||||
"default": "./dist/commonjs/v3/zodMessageHandler.js"
|
||||
}
|
||||
},
|
||||
"./v3/zodNamespace": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodNamespace.ts",
|
||||
"types": "./dist/esm/v3/zodNamespace.d.ts",
|
||||
"default": "./dist/esm/v3/zodNamespace.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/zodNamespace.d.ts",
|
||||
"default": "./dist/commonjs/v3/zodNamespace.js"
|
||||
}
|
||||
},
|
||||
"./v3/zodSocket": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodSocket.ts",
|
||||
"types": "./dist/esm/v3/zodSocket.d.ts",
|
||||
"default": "./dist/esm/v3/zodSocket.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/zodSocket.d.ts",
|
||||
"default": "./dist/commonjs/v3/zodSocket.js"
|
||||
}
|
||||
},
|
||||
"./v3/zodIpc": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/zodIpc.ts",
|
||||
"types": "./dist/esm/v3/zodIpc.d.ts",
|
||||
"default": "./dist/esm/v3/zodIpc.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/zodIpc.d.ts",
|
||||
"default": "./dist/commonjs/v3/zodIpc.js"
|
||||
}
|
||||
},
|
||||
"./v3/utils/timers": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/utils/timers.ts",
|
||||
"types": "./dist/esm/v3/utils/timers.d.ts",
|
||||
"default": "./dist/esm/v3/utils/timers.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/utils/timers.d.ts",
|
||||
"default": "./dist/commonjs/v3/utils/timers.js"
|
||||
}
|
||||
},
|
||||
"./v3/workers": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/workers/index.ts",
|
||||
"types": "./dist/esm/v3/workers/index.d.ts",
|
||||
"default": "./dist/esm/v3/workers/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/workers/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/workers/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/schemas": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/schemas/index.ts",
|
||||
"types": "./dist/esm/v3/schemas/index.d.ts",
|
||||
"default": "./dist/esm/v3/schemas/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/schemas/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/schemas/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/runEngineWorker": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/runEngineWorker/index.ts",
|
||||
"types": "./dist/esm/v3/runEngineWorker/index.d.ts",
|
||||
"default": "./dist/esm/v3/runEngineWorker/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/runEngineWorker/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/runEngineWorker/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/machines": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/machines/index.ts",
|
||||
"types": "./dist/esm/v3/machines/index.d.ts",
|
||||
"default": "./dist/esm/v3/machines/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/machines/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/machines/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/serverOnly": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/serverOnly/index.ts",
|
||||
"types": "./dist/esm/v3/serverOnly/index.d.ts",
|
||||
"default": "./dist/esm/v3/serverOnly/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/serverOnly/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/serverOnly/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/isomorphic": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/isomorphic/index.ts",
|
||||
"types": "./dist/esm/v3/isomorphic/index.d.ts",
|
||||
"default": "./dist/esm/v3/isomorphic/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/isomorphic/index.d.ts",
|
||||
"default": "./dist/commonjs/v3/isomorphic/index.js"
|
||||
}
|
||||
},
|
||||
"./v3/sdk-scope-storage": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/sdkScope/storage-node.ts",
|
||||
"types": "./dist/esm/v3/sdkScope/storage-node.d.ts",
|
||||
"default": "./dist/esm/v3/sdkScope/storage-node.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/sdkScope/storage-node.d.ts",
|
||||
"default": "./dist/commonjs/v3/sdkScope/storage-node.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/commonjs/index.js",
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js"
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script bundles superjson and its dependency (copy-anything) into
|
||||
* vendored CJS and ESM bundles to avoid the ERR_REQUIRE_ESM error.
|
||||
*
|
||||
* superjson v2.x is ESM-only, which causes issues on:
|
||||
* - Node.js versions before 22.12.0 (require(ESM) not enabled by default)
|
||||
* - AWS Lambda (intentionally disables require(ESM))
|
||||
*
|
||||
* The output files are gitignored and regenerated during each build.
|
||||
* This script runs automatically as part of `pnpm run build`.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/bundle-superjson.mjs # Bundle to src/v3/vendor
|
||||
* node scripts/bundle-superjson.mjs --copy # Also copy to dist directories
|
||||
*/
|
||||
|
||||
import * as esbuild from "esbuild";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFileSync, mkdirSync, copyFileSync } from "node:fs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = join(__dirname, "..");
|
||||
const vendorDir = join(packageRoot, "src", "v3", "vendor");
|
||||
|
||||
// Get the installed superjson version for the banner
|
||||
const superjsonPkg = JSON.parse(
|
||||
readFileSync(join(packageRoot, "node_modules", "superjson", "package.json"), "utf-8")
|
||||
);
|
||||
const banner = `/**
|
||||
* Bundled superjson v${superjsonPkg.version}
|
||||
*
|
||||
* This file is auto-generated by scripts/bundle-superjson.mjs
|
||||
* Do not edit directly - run the script to regenerate.
|
||||
*
|
||||
* Original package: https://github.com/flightcontrolhq/superjson
|
||||
* License: MIT
|
||||
*/`;
|
||||
|
||||
async function bundle() {
|
||||
// Ensure vendor directory exists
|
||||
mkdirSync(vendorDir, { recursive: true });
|
||||
|
||||
// Bundle for CommonJS
|
||||
await esbuild.build({
|
||||
entryPoints: [join(packageRoot, "node_modules", "superjson", "dist", "index.js")],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
outfile: join(vendorDir, "superjson.cjs"),
|
||||
banner: { js: banner },
|
||||
// Don't minify to keep it debuggable
|
||||
minify: false,
|
||||
});
|
||||
|
||||
// Bundle for ESM
|
||||
await esbuild.build({
|
||||
entryPoints: [join(packageRoot, "node_modules", "superjson", "dist", "index.js")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
outfile: join(vendorDir, "superjson.mjs"),
|
||||
banner: { js: banner },
|
||||
minify: false,
|
||||
});
|
||||
|
||||
console.log("Bundled superjson v" + superjsonPkg.version);
|
||||
console.log(" -> src/v3/vendor/superjson.cjs (CommonJS)");
|
||||
console.log(" -> src/v3/vendor/superjson.mjs (ESM)");
|
||||
|
||||
// Copy to dist directories if --copy flag is passed
|
||||
if (process.argv.includes("--copy")) {
|
||||
const distCommonjsVendor = join(packageRoot, "dist", "commonjs", "v3", "vendor");
|
||||
const distEsmVendor = join(packageRoot, "dist", "esm", "v3", "vendor");
|
||||
|
||||
mkdirSync(distCommonjsVendor, { recursive: true });
|
||||
mkdirSync(distEsmVendor, { recursive: true });
|
||||
|
||||
copyFileSync(join(vendorDir, "superjson.cjs"), join(distCommonjsVendor, "superjson.cjs"));
|
||||
copyFileSync(join(vendorDir, "superjson.mjs"), join(distEsmVendor, "superjson.mjs"));
|
||||
|
||||
console.log("Copied to dist directories");
|
||||
}
|
||||
}
|
||||
|
||||
bundle().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/** A very simple debounce. Will only execute after the specified delay has elapsed since the last call. */
|
||||
export function debounce(
|
||||
func: (...args: any[]) => void,
|
||||
delayMs: number
|
||||
): (...args: any[]) => void {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: any[]) => {
|
||||
// Clear any existing timeout
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Set a new timeout with the latest args
|
||||
timeoutId = setTimeout(() => {
|
||||
func(...args);
|
||||
timeoutId = null;
|
||||
}, delayMs);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { EventFilter } from "./schemas/eventFilter.js";
|
||||
|
||||
// EventFilter is a recursive type, where the keys are strings and the values are an array of strings, numbers, booleans, or objects.
|
||||
// If the values of the array are strings, numbers, or booleans, than we are matching against the value of the payload.
|
||||
// If the values of the array are objects, then we are doing content filtering
|
||||
// An example would be [{ $endsWith: ".png" }, { $startsWith: "images/" } ]
|
||||
export function eventFilterMatches(payload: any, filter: EventFilter): boolean {
|
||||
if (payload === undefined || payload === null) {
|
||||
if (Object.entries(filter).length === 0) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [patternKey, patternValue] of Object.entries(filter)) {
|
||||
const payloadValue = payload[patternKey];
|
||||
|
||||
if (Array.isArray(patternValue)) {
|
||||
if (patternValue.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check to see if all the items in the array are a string
|
||||
if ((patternValue as unknown[]).every((item) => typeof item === "string")) {
|
||||
if ((patternValue as string[]).includes(payloadValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check to see if all the items in the array are a number
|
||||
if ((patternValue as unknown[]).every((item) => typeof item === "number")) {
|
||||
if ((patternValue as number[]).includes(payloadValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check to see if all the items in the array are a boolean
|
||||
if ((patternValue as unknown[]).every((item) => typeof item === "boolean")) {
|
||||
if ((patternValue as boolean[]).includes(payloadValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now we know that all the items in the array are objects
|
||||
const objectArray = patternValue as Exclude<
|
||||
typeof patternValue,
|
||||
number[] | string[] | boolean[]
|
||||
>;
|
||||
|
||||
if (!contentFiltersMatches(payloadValue, objectArray)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
} else if (typeof patternValue === "object") {
|
||||
if (Array.isArray(payloadValue)) {
|
||||
if (!payloadValue.some((item) => eventFilterMatches(item, patternValue))) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!eventFilterMatches(payloadValue, patternValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
type ContentFilters = Exclude<EventFilter[string], EventFilter | string[] | number[] | boolean[]>;
|
||||
|
||||
function contentFiltersMatches(actualValue: any, contentFilters: ContentFilters): boolean {
|
||||
for (const contentFilter of contentFilters) {
|
||||
if (typeof contentFilter === "object") {
|
||||
if (!contentFilterMatches(actualValue, contentFilter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function contentFilterMatches(actualValue: any, contentFilter: ContentFilters[number]): boolean {
|
||||
if ("$endsWith" in contentFilter) {
|
||||
if (typeof actualValue !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue.endsWith(contentFilter.$endsWith);
|
||||
}
|
||||
|
||||
if ("$startsWith" in contentFilter) {
|
||||
if (typeof actualValue !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue.startsWith(contentFilter.$startsWith);
|
||||
}
|
||||
|
||||
if ("$anythingBut" in contentFilter) {
|
||||
if (Array.isArray(contentFilter.$anythingBut)) {
|
||||
if ((contentFilter.$anythingBut as any[]).includes(actualValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentFilter.$anythingBut === actualValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("$exists" in contentFilter) {
|
||||
if (contentFilter.$exists) {
|
||||
return actualValue !== undefined;
|
||||
}
|
||||
|
||||
return actualValue === undefined;
|
||||
}
|
||||
|
||||
if ("$gt" in contentFilter) {
|
||||
if (typeof actualValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue > contentFilter.$gt;
|
||||
}
|
||||
|
||||
if ("$lt" in contentFilter) {
|
||||
if (typeof actualValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue < contentFilter.$lt;
|
||||
}
|
||||
|
||||
if ("$gte" in contentFilter) {
|
||||
if (typeof actualValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue >= contentFilter.$gte;
|
||||
}
|
||||
|
||||
if ("$lte" in contentFilter) {
|
||||
if (typeof actualValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue <= contentFilter.$lte;
|
||||
}
|
||||
|
||||
if ("$between" in contentFilter) {
|
||||
if (typeof actualValue !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return actualValue >= contentFilter.$between[0] && actualValue <= contentFilter.$between[1];
|
||||
}
|
||||
|
||||
if ("$includes" in contentFilter) {
|
||||
if (Array.isArray(actualValue)) {
|
||||
return actualValue.includes(contentFilter.$includes);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use localCompare
|
||||
if ("$ignoreCaseEquals" in contentFilter) {
|
||||
if (typeof actualValue !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
actualValue.localeCompare(contentFilter.$ignoreCaseEquals, undefined, {
|
||||
sensitivity: "accent",
|
||||
}) === 0
|
||||
);
|
||||
}
|
||||
|
||||
if ("$isNull" in contentFilter) {
|
||||
if (contentFilter.$isNull) {
|
||||
return actualValue === null;
|
||||
}
|
||||
|
||||
return actualValue !== null;
|
||||
}
|
||||
|
||||
if ("$not" in contentFilter) {
|
||||
if (Array.isArray(actualValue)) {
|
||||
return !actualValue.includes(contentFilter.$not);
|
||||
} else if (
|
||||
typeof actualValue === "number" ||
|
||||
typeof actualValue === "boolean" ||
|
||||
typeof actualValue === "string"
|
||||
) {
|
||||
return actualValue !== contentFilter.$not;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types.js";
|
||||
export * from "./utils.js";
|
||||
export * from "./schemas/json.js";
|
||||
export * from "./version.js";
|
||||
@@ -0,0 +1,295 @@
|
||||
// Create a logger class that uses the debug package internally
|
||||
|
||||
/**
|
||||
* Represents different log levels.
|
||||
* - `"log"`: Only essential messages.
|
||||
* - `"error"`: Errors and essential messages.
|
||||
* - `"warn"`: Warnings, Errors and essential messages.
|
||||
* - `"info"`: Info, Warnings, Errors and essential messages.
|
||||
* - `"debug"`: Everything.
|
||||
*/
|
||||
import { env } from "node:process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { trace, context } from "@opentelemetry/api";
|
||||
|
||||
export type LogLevel = "log" | "error" | "warn" | "info" | "debug" | "verbose";
|
||||
|
||||
const logLevels: Array<LogLevel> = ["log", "error", "warn", "info", "debug", "verbose"];
|
||||
|
||||
export class Logger {
|
||||
#name: string;
|
||||
readonly #level: number;
|
||||
#filteredKeys: string[] = [];
|
||||
#jsonReplacer?: (key: string, value: unknown) => unknown;
|
||||
#additionalFields: () => Record<string, unknown>;
|
||||
|
||||
// Add a static "onError" method that will be called when an error is logged
|
||||
static onError: (message: string, ...args: Array<Record<string, unknown> | undefined>) => void;
|
||||
|
||||
// Optional static sink called with the fully-structured log for every emitted line.
|
||||
// Used (e.g.) to fan logs out to a dev-only telnet stream. Must not re-enter the Logger.
|
||||
static onLog?: (structuredLog: Record<string, unknown>) => void;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
level: LogLevel = "info",
|
||||
filteredKeys: string[] = [],
|
||||
jsonReplacer?: (key: string, value: unknown) => unknown,
|
||||
additionalFields?: () => Record<string, unknown>
|
||||
) {
|
||||
this.#name = name;
|
||||
this.#level = logLevels.indexOf((env.TRIGGER_LOG_LEVEL ?? level) as LogLevel);
|
||||
this.#filteredKeys = filteredKeys;
|
||||
this.#jsonReplacer = createReplacer(jsonReplacer);
|
||||
this.#additionalFields = additionalFields ?? (() => ({}));
|
||||
}
|
||||
|
||||
child(fields: Record<string, unknown>) {
|
||||
return new Logger(
|
||||
this.#name,
|
||||
logLevels[this.#level],
|
||||
this.#filteredKeys,
|
||||
this.#jsonReplacer,
|
||||
() => ({ ...this.#additionalFields(), ...fields })
|
||||
);
|
||||
}
|
||||
|
||||
// Return a new Logger instance with the same name and a new log level
|
||||
// but filter out the keys from the log messages (at any level)
|
||||
filter(...keys: string[]) {
|
||||
return new Logger(this.#name, logLevels[this.#level], keys, this.#jsonReplacer);
|
||||
}
|
||||
|
||||
static satisfiesLogLevel(logLevel: LogLevel, setLevel: LogLevel) {
|
||||
return logLevels.indexOf(logLevel) <= logLevels.indexOf(setLevel);
|
||||
}
|
||||
|
||||
log(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 0) return;
|
||||
|
||||
this.#structuredLog(console.log, message, "log", ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 1) return;
|
||||
|
||||
this.#structuredLog(console.error, message, "error", ...args);
|
||||
|
||||
const ignoreError = args.some((arg) => arg?.ignoreError);
|
||||
|
||||
if (Logger.onError && !ignoreError) {
|
||||
Logger.onError(message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 2) return;
|
||||
|
||||
this.#structuredLog(console.warn, message, "warn", ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 3) return;
|
||||
|
||||
this.#structuredLog(console.info, message, "info", ...args);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 4) return;
|
||||
|
||||
this.#structuredLog(console.debug, message, "debug", ...args);
|
||||
}
|
||||
|
||||
verbose(message: string, ...args: Array<Record<string, unknown> | undefined>) {
|
||||
if (this.#level < 5) return;
|
||||
|
||||
this.#structuredLog(console.log, message, "verbose", ...args);
|
||||
}
|
||||
|
||||
#structuredLog(
|
||||
loggerFunction: (message: string, ...args: any[]) => void,
|
||||
message: string,
|
||||
level: string,
|
||||
...args: Array<Record<string, unknown> | undefined>
|
||||
) {
|
||||
// Get the current context from trace if it exists
|
||||
const currentSpan = trace.getSpan(context.active());
|
||||
|
||||
const structuredError = extractStructuredErrorFromArgs(...args);
|
||||
const structuredMessage = extractStructuredMessageFromArgs(...args);
|
||||
|
||||
const structuredLog = {
|
||||
...structureArgs(safeJsonClone(args) as Record<string, unknown>[], this.#filteredKeys),
|
||||
...this.#additionalFields(),
|
||||
...(structuredError ? { error: structuredError } : {}),
|
||||
timestamp: new Date(),
|
||||
name: this.#name,
|
||||
message,
|
||||
...(structuredMessage ? { $message: structuredMessage } : {}),
|
||||
level,
|
||||
traceId:
|
||||
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().traceId : undefined,
|
||||
parentSpanId:
|
||||
currentSpan && currentSpan.isRecording() ? currentSpan?.spanContext().spanId : undefined,
|
||||
};
|
||||
|
||||
// If the span is not recording, and it's a debug log, mark it so we can filter it out when we forward it
|
||||
if (currentSpan && !currentSpan.isRecording() && level === "debug") {
|
||||
structuredLog.skipForwarding = true;
|
||||
}
|
||||
|
||||
if (Logger.onLog) {
|
||||
try {
|
||||
Logger.onLog(structuredLog);
|
||||
} catch {
|
||||
// A sink must never break logging — and must never re-enter the Logger.
|
||||
}
|
||||
}
|
||||
|
||||
loggerFunction(JSON.stringify(structuredLog, this.#jsonReplacer));
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if args is an error object
|
||||
// Or if args contains an error object at the "error" key
|
||||
// In both cases, return the error object as a structured error
|
||||
function extractStructuredErrorFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||
const error = args.find((arg) => arg instanceof Error) as Error | undefined;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
metadata: "metadata" in error ? error.metadata : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredError = args.find((arg) => arg?.error);
|
||||
|
||||
if (structuredError && structuredError.error instanceof Error) {
|
||||
return {
|
||||
message: structuredError.error.message,
|
||||
stack: structuredError.error.stack,
|
||||
name: structuredError.error.name,
|
||||
metadata: "metadata" in structuredError.error ? structuredError.error.metadata : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function extractStructuredMessageFromArgs(...args: Array<Record<string, unknown> | undefined>) {
|
||||
// Check to see if there is a `message` key in the args, and if so, return it
|
||||
const structuredMessage = args.find((arg) => arg?.message);
|
||||
|
||||
if (structuredMessage) {
|
||||
return structuredMessage.message;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function createReplacer(replacer?: (key: string, value: unknown) => unknown) {
|
||||
return (key: string, value: unknown) => {
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (replacer) {
|
||||
return replacer(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
// Replacer function for JSON.stringify that converts BigInts to strings
|
||||
function bigIntReplacer(_key: string, value: unknown) {
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeJsonClone(obj: unknown) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj, bigIntReplacer));
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If args is has a single item that is an object, return that object
|
||||
function structureArgs(args: Array<Record<string, unknown>>, filteredKeys: string[] = []) {
|
||||
if (!args) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 1 && typeof args[0] === "object") {
|
||||
return filterKeys(JSON.parse(JSON.stringify(args[0], bigIntReplacer)), filteredKeys);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Recursively filter out keys from an object, including nested objects, and arrays
|
||||
function filterKeys(obj: unknown, keys: string[]): any {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => filterKeys(item, keys));
|
||||
}
|
||||
|
||||
const filteredObj: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (keys.includes(key)) {
|
||||
if (value) {
|
||||
filteredObj[key] = `[filtered ${prettyPrintBytes(value)}]`;
|
||||
} else {
|
||||
filteredObj[key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredObj[key] = filterKeys(value, keys);
|
||||
}
|
||||
|
||||
return filteredObj;
|
||||
}
|
||||
|
||||
function prettyPrintBytes(value: unknown): string {
|
||||
if (env.NODE_ENV === "production") {
|
||||
return "skipped size";
|
||||
}
|
||||
|
||||
const sizeInBytes = getSizeInBytes(value);
|
||||
|
||||
if (sizeInBytes < 1024) {
|
||||
return `${sizeInBytes} bytes`;
|
||||
}
|
||||
|
||||
if (sizeInBytes < 1024 * 1024) {
|
||||
return `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
|
||||
if (sizeInBytes < 1024 * 1024 * 1024) {
|
||||
return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function getSizeInBytes(value: unknown) {
|
||||
const jsonString = JSON.stringify(value);
|
||||
|
||||
return Buffer.byteLength(jsonString, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export function calculateResetAt(
|
||||
resets: string | undefined | null,
|
||||
format:
|
||||
| "unix_timestamp"
|
||||
| "iso_8601"
|
||||
| "iso_8601_duration_openai_variant"
|
||||
| "unix_timestamp_in_ms",
|
||||
now: Date = new Date()
|
||||
): Date | undefined {
|
||||
if (!resets) return;
|
||||
|
||||
switch (format) {
|
||||
case "iso_8601_duration_openai_variant": {
|
||||
return calculateISO8601DurationOpenAIVariantResetAt(resets, now);
|
||||
}
|
||||
case "iso_8601": {
|
||||
return calculateISO8601ResetAt(resets, now);
|
||||
}
|
||||
case "unix_timestamp": {
|
||||
return calculateUnixTimestampResetAt(resets, now);
|
||||
}
|
||||
case "unix_timestamp_in_ms": {
|
||||
return calculateUnixTimestampInMsResetAt(resets, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateUnixTimestampResetAt(resets: string, now: Date = new Date()): Date | undefined {
|
||||
// Check if the input is null or undefined
|
||||
if (!resets) return undefined;
|
||||
|
||||
// Convert the string to a number
|
||||
const resetAt = parseInt(resets, 10);
|
||||
|
||||
// If the string doesn't match the expected format, return undefined
|
||||
if (isNaN(resetAt)) return undefined;
|
||||
|
||||
// Return the date
|
||||
return new Date(resetAt * 1000);
|
||||
}
|
||||
|
||||
function calculateUnixTimestampInMsResetAt(
|
||||
resets: string,
|
||||
now: Date = new Date()
|
||||
): Date | undefined {
|
||||
// Check if the input is null or undefined
|
||||
if (!resets) return undefined;
|
||||
|
||||
// Convert the string to a number
|
||||
const resetAt = parseInt(resets, 10);
|
||||
|
||||
// If the string doesn't match the expected format, return undefined
|
||||
if (isNaN(resetAt)) return undefined;
|
||||
|
||||
// Return the date
|
||||
return new Date(resetAt);
|
||||
}
|
||||
|
||||
function calculateISO8601ResetAt(resets: string, now: Date = new Date()): Date | undefined {
|
||||
// Check if the input is null or undefined
|
||||
if (!resets) return undefined;
|
||||
|
||||
// Parse the date
|
||||
const resetAt = new Date(resets);
|
||||
|
||||
// If the string doesn't match the expected format, return undefined
|
||||
if (isNaN(resetAt.getTime())) return undefined;
|
||||
|
||||
return resetAt;
|
||||
}
|
||||
|
||||
function calculateISO8601DurationOpenAIVariantResetAt(
|
||||
resets: string,
|
||||
now: Date = new Date()
|
||||
): Date | undefined {
|
||||
// Check if the input is null or undefined
|
||||
if (!resets) return undefined;
|
||||
|
||||
// Regular expression to match the duration string pattern
|
||||
const pattern = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+)ms)?$/;
|
||||
const match = resets.match(pattern);
|
||||
|
||||
// If the string doesn't match the expected format, return undefined
|
||||
if (!match) return undefined;
|
||||
|
||||
// Extract days, hours, minutes, seconds, and milliseconds from the string
|
||||
const days = parseInt(match[1] ?? "0", 10) || 0;
|
||||
const hours = parseInt(match[2] ?? "0", 10) || 0;
|
||||
const minutes = parseInt(match[3] ?? "0", 10) || 0;
|
||||
const seconds = parseFloat(match[4] ?? "0") || 0;
|
||||
const milliseconds = parseInt(match[5] ?? "0", 10) || 0;
|
||||
|
||||
// Calculate the future date based on the current date plus the extracted time
|
||||
const resetAt = new Date(now);
|
||||
resetAt.setDate(resetAt.getDate() + days);
|
||||
resetAt.setHours(resetAt.getHours() + hours);
|
||||
resetAt.setMinutes(resetAt.getMinutes() + minutes);
|
||||
resetAt.setSeconds(resetAt.getSeconds() + Math.floor(seconds));
|
||||
resetAt.setMilliseconds(
|
||||
resetAt.getMilliseconds() + (seconds - Math.floor(seconds)) * 1000 + milliseconds
|
||||
);
|
||||
|
||||
return resetAt;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const stringPatternMatchers = [
|
||||
z.object({
|
||||
$endsWith: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
$startsWith: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
$ignoreCaseEquals: z.string(),
|
||||
}),
|
||||
] as const;
|
||||
|
||||
const EventMatcherSchema = z.union([
|
||||
/** Match against a string */
|
||||
z.array(z.string()),
|
||||
/** Match against a number */
|
||||
z.array(z.number()),
|
||||
/** Match against a boolean */
|
||||
z.array(z.boolean()),
|
||||
z.array(
|
||||
z.union([
|
||||
...stringPatternMatchers,
|
||||
z.object({
|
||||
$exists: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
$isNull: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
$anythingBut: z.union([z.string(), z.number(), z.boolean()]),
|
||||
}),
|
||||
z.object({
|
||||
$anythingBut: z.union([z.array(z.string()), z.array(z.number()), z.array(z.boolean())]),
|
||||
}),
|
||||
z.object({
|
||||
$gt: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
$lt: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
$gte: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
$lte: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
$between: z.tuple([z.number(), z.number()]),
|
||||
}),
|
||||
z.object({
|
||||
$includes: z.union([z.string(), z.number(), z.boolean()]),
|
||||
}),
|
||||
z.object({
|
||||
$not: z.union([z.string(), z.number(), z.boolean()]),
|
||||
}),
|
||||
])
|
||||
),
|
||||
]);
|
||||
|
||||
type EventMatcher = z.infer<typeof EventMatcherSchema>;
|
||||
|
||||
/** A filter for matching against data */
|
||||
export type EventFilter = { [key: string]: EventMatcher | EventFilter };
|
||||
|
||||
export const EventFilterSchema: z.ZodType<EventFilter> = z.lazy(() =>
|
||||
z.record(z.union([EventMatcherSchema, EventFilterSchema]))
|
||||
);
|
||||
|
||||
export const EventRuleSchema = z.object({
|
||||
event: z.string().or(z.array(z.string())),
|
||||
source: z.string(),
|
||||
payload: EventFilterSchema.optional(),
|
||||
context: EventFilterSchema.optional(),
|
||||
});
|
||||
|
||||
export type EventRule = z.infer<typeof EventRuleSchema>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./json.js";
|
||||
export * from "./eventFilter.js";
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
||||
type Literal = z.infer<typeof LiteralSchema>;
|
||||
|
||||
export type DeserializedJson = Literal | { [key: string]: DeserializedJson } | DeserializedJson[];
|
||||
|
||||
export const DeserializedJsonSchema: z.ZodType<DeserializedJson> = z.lazy(() =>
|
||||
z.union([LiteralSchema, z.array(DeserializedJsonSchema), z.record(DeserializedJsonSchema)])
|
||||
);
|
||||
|
||||
const SerializableSchema = z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.date(),
|
||||
z.undefined(),
|
||||
z.symbol(),
|
||||
]);
|
||||
type Serializable = z.infer<typeof SerializableSchema>;
|
||||
|
||||
export type SerializableJson =
|
||||
| Serializable
|
||||
| { [key: string]: SerializableJson }
|
||||
| SerializableJson[];
|
||||
|
||||
export const SerializableJsonSchema: z.ZodType<SerializableJson> = z.lazy(() =>
|
||||
z.union([SerializableSchema, z.array(SerializableJsonSchema), z.record(SerializableJsonSchema)])
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
// See this for more: https://twitter.com/mattpocockuk/status/1653403198885904387?s=20
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
@@ -0,0 +1,40 @@
|
||||
export function assertExhaustive(x: never): never {
|
||||
throw new Error("Unexpected object: " + x);
|
||||
}
|
||||
|
||||
export async function tryCatch<T, E = Error>(
|
||||
promise: Promise<T> | undefined
|
||||
): Promise<[null, T] | [E, null]> {
|
||||
if (!promise) {
|
||||
return [null, undefined as T];
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await promise;
|
||||
return [null, data];
|
||||
} catch (error) {
|
||||
return [error as E, null];
|
||||
}
|
||||
}
|
||||
|
||||
export type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
|
||||
export function promiseWithResolvers<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: any) => void;
|
||||
|
||||
const promise = new Promise<T>((_resolve, _reject) => {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { ApiClient } from "./index.js";
|
||||
|
||||
type ReceivedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
};
|
||||
|
||||
type RequestHandler = (request: ReceivedRequest, response: ServerResponse) => void | Promise<void>;
|
||||
|
||||
describe("ApiClient bulk actions", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let receivedRequests: ReceivedRequest[] = [];
|
||||
let requestHandler: RequestHandler | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
receivedRequests = [];
|
||||
requestHandler = undefined;
|
||||
|
||||
server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", async () => {
|
||||
const received = {
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
} satisfies ReceivedRequest;
|
||||
receivedRequests.push(received);
|
||||
|
||||
try {
|
||||
if (requestHandler) {
|
||||
await requestHandler(received, res);
|
||||
} else {
|
||||
json(res, { error: "No handler" }, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
json(res, { error: error instanceof Error ? error.message : String(error) }, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("posts the exact create bulk action request body", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_created" });
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const result = await client.createBulkAction({
|
||||
action: "replay",
|
||||
filter: { status: ["FAILED"], taskIdentifier: "my-task" },
|
||||
name: "Replay failures",
|
||||
targetRegion: "eu_1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "bulk_created" });
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions");
|
||||
expect(receivedRequests[0]?.headers.authorization).toBe("Bearer tr_test_key");
|
||||
expect(JSON.parse(receivedRequests[0]?.body ?? "{}")).toEqual({
|
||||
action: "replay",
|
||||
filter: { status: ["FAILED"], taskIdentifier: "my-task" },
|
||||
name: "Replay failures",
|
||||
targetRegion: "eu_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists bulk actions with cursor pagination params and parses dates", async () => {
|
||||
const createdAt = "2026-07-01T10:00:00.000Z";
|
||||
const completedAt = "2026-07-01T10:05:00.000Z";
|
||||
requestHandler = (_request, response) =>
|
||||
json(response, {
|
||||
data: [
|
||||
{
|
||||
id: "bulk_listed",
|
||||
name: "Cancel queued runs",
|
||||
type: "CANCEL",
|
||||
status: "COMPLETED",
|
||||
counts: { total: 3, success: 2, failure: 1 },
|
||||
createdAt,
|
||||
completedAt,
|
||||
},
|
||||
],
|
||||
pagination: { next: "cursor_next", previous: "cursor_previous" },
|
||||
});
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const page = await client.listBulkActions({ limit: 2, after: "cursor_after" });
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("GET");
|
||||
const url = new URL(receivedRequests[0]?.url ?? "", baseUrl);
|
||||
expect(url.pathname).toBe("/api/v1/bulk-actions");
|
||||
expect(url.searchParams.get("page[size]")).toBe("2");
|
||||
expect(url.searchParams.get("page[after]")).toBe("cursor_after");
|
||||
expect(page.pagination).toEqual({ next: "cursor_next", previous: "cursor_previous" });
|
||||
expect(page.data[0]?.createdAt).toEqual(new Date(createdAt));
|
||||
expect(page.data[0]?.completedAt).toEqual(new Date(completedAt));
|
||||
});
|
||||
|
||||
it("auto-paginates bulk action lists", async () => {
|
||||
requestHandler = (request, response) => {
|
||||
const url = new URL(request.url, baseUrl);
|
||||
if (!url.searchParams.has("page[after]")) {
|
||||
return json(response, {
|
||||
data: [bulkActionObject("bulk_first")],
|
||||
pagination: { next: "cursor_next" },
|
||||
});
|
||||
}
|
||||
|
||||
expect(url.searchParams.get("page[after]")).toBe("cursor_next");
|
||||
return json(response, {
|
||||
data: [bulkActionObject("bulk_second")],
|
||||
pagination: {},
|
||||
});
|
||||
};
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const ids: string[] = [];
|
||||
|
||||
for await (const bulkAction of client.listBulkActions({ limit: 1 })) {
|
||||
ids.push(bulkAction.id);
|
||||
}
|
||||
|
||||
expect(ids).toEqual(["bulk_first", "bulk_second"]);
|
||||
expect(receivedRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("retrieves a bulk action by id", async () => {
|
||||
requestHandler = (_request, response) => json(response, bulkActionObject("bulk_retrieve"));
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const bulkAction = await client.retrieveBulkAction("bulk_retrieve");
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("GET");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions/bulk_retrieve");
|
||||
expect(bulkAction.id).toBe("bulk_retrieve");
|
||||
});
|
||||
|
||||
it("aborts a bulk action by id", async () => {
|
||||
requestHandler = (_request, response) => json(response, { id: "bulk_abort" });
|
||||
|
||||
const client = new ApiClient(baseUrl, "tr_test_key");
|
||||
const result = await client.abortBulkAction("bulk_abort");
|
||||
|
||||
expect(receivedRequests[0]?.method).toBe("POST");
|
||||
expect(receivedRequests[0]?.url).toBe("/api/v1/bulk-actions/bulk_abort/abort");
|
||||
expect(result).toEqual({ id: "bulk_abort" });
|
||||
});
|
||||
});
|
||||
|
||||
function json(response: ServerResponse, body: unknown, status = 200) {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function bulkActionObject(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: "REPLAY",
|
||||
status: "PENDING",
|
||||
counts: { total: 1, success: 0, failure: 0 },
|
||||
createdAt: "2026-07-01T10:00:00.000Z",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { z } from "zod";
|
||||
import { fromZodError, ValidationError } from "zod-validation-error";
|
||||
import type { RetryOptions } from "../schemas/index.js";
|
||||
import { calculateNextRetryDelay } from "../utils/retries.js";
|
||||
import { ApiConnectionError, ApiError, ApiSchemaValidationError } from "./errors.js";
|
||||
|
||||
import type { Attributes, Span } from "@opentelemetry/api";
|
||||
import { context, propagation } from "@opentelemetry/api";
|
||||
import { suppressTracing } from "@opentelemetry/core";
|
||||
import { EventSource, type ErrorEvent } from "eventsource";
|
||||
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
|
||||
import type { TriggerTracer } from "../tracer.js";
|
||||
import { randomUUID } from "../utils/crypto.js";
|
||||
import { accessoryAttributes } from "../utils/styleAttributes.js";
|
||||
import type {
|
||||
CursorPageParams,
|
||||
CursorPageResponse,
|
||||
OffsetLimitPageParams,
|
||||
OffsetLimitPageResponse,
|
||||
} from "./pagination.js";
|
||||
import { CursorPage, OffsetLimitPage } from "./pagination.js";
|
||||
|
||||
export const defaultRetryOptions = {
|
||||
maxAttempts: 3,
|
||||
factor: 2,
|
||||
minTimeoutInMs: 1000,
|
||||
maxTimeoutInMs: 60000,
|
||||
randomize: false,
|
||||
} satisfies RetryOptions;
|
||||
|
||||
export type ZodFetchOptions<TData = any> = {
|
||||
retry?: RetryOptions;
|
||||
tracer?: TriggerTracer;
|
||||
name?: string;
|
||||
attributes?: Attributes;
|
||||
icon?: string;
|
||||
onResponseBody?: (body: TData, span: Span) => void;
|
||||
prepareData?: (data: TData, response: Response) => Promise<TData> | TData;
|
||||
};
|
||||
|
||||
export type AnyZodFetchOptions = ZodFetchOptions<any>;
|
||||
|
||||
export type ApiRequestOptions = Pick<ZodFetchOptions, "retry"> & {
|
||||
additionalHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
type KeysEnum<T> = { [P in keyof Required<T>]: true };
|
||||
|
||||
// This is required so that we can determine if a given object matches the ApiRequestOptions
|
||||
// type at runtime. While this requires duplication, it is enforced by the TypeScript
|
||||
// compiler such that any missing / extraneous keys will cause an error.
|
||||
const requestOptionsKeys: KeysEnum<ApiRequestOptions> = {
|
||||
retry: true,
|
||||
additionalHeaders: true,
|
||||
};
|
||||
|
||||
export const isRequestOptions = (obj: unknown): obj is ApiRequestOptions => {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
!isEmptyObj(obj) &&
|
||||
Object.keys(obj).every((k) => hasOwn(requestOptionsKeys, k))
|
||||
);
|
||||
};
|
||||
|
||||
interface FetchCursorPageParams extends CursorPageParams {
|
||||
query?: URLSearchParams;
|
||||
}
|
||||
|
||||
interface FetchOffsetLimitPageParams extends OffsetLimitPageParams {
|
||||
query?: URLSearchParams;
|
||||
}
|
||||
|
||||
export function zodfetch<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
schema: TResponseBodySchema,
|
||||
url: string,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions<z.output<TResponseBodySchema>>
|
||||
): ApiPromise<z.output<TResponseBodySchema>> {
|
||||
return new ApiPromise(_doZodFetch(schema, url, requestInit, options));
|
||||
}
|
||||
|
||||
export function zodfetchCursorPage<TItemSchema extends z.ZodTypeAny>(
|
||||
schema: TItemSchema,
|
||||
url: string,
|
||||
params: FetchCursorPageParams,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions
|
||||
) {
|
||||
const query = new URLSearchParams(params.query);
|
||||
|
||||
if (params.limit) {
|
||||
query.set("page[size]", String(params.limit));
|
||||
}
|
||||
|
||||
if (params.after) {
|
||||
query.set("page[after]", params.after);
|
||||
}
|
||||
|
||||
if (params.before) {
|
||||
query.set("page[before]", params.before);
|
||||
}
|
||||
|
||||
const cursorPageSchema = z.object({
|
||||
data: z.array(schema),
|
||||
pagination: z.object({
|
||||
next: z.string().optional(),
|
||||
previous: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const $url = new URL(url);
|
||||
$url.search = query.toString();
|
||||
|
||||
const fetchResult = _doZodFetch(cursorPageSchema, $url.href, requestInit, options);
|
||||
|
||||
return new CursorPagePromise(fetchResult, schema, url, params, requestInit, options);
|
||||
}
|
||||
|
||||
export function zodfetchOffsetLimitPage<TItemSchema extends z.ZodTypeAny>(
|
||||
schema: TItemSchema,
|
||||
url: string,
|
||||
params: FetchOffsetLimitPageParams,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions
|
||||
) {
|
||||
const query = new URLSearchParams(params.query);
|
||||
|
||||
if (params.limit) {
|
||||
query.set("perPage", String(params.limit));
|
||||
}
|
||||
|
||||
if (params.page) {
|
||||
query.set("page", String(params.page));
|
||||
}
|
||||
|
||||
const offsetLimitPageSchema = z.object({
|
||||
data: z.array(schema),
|
||||
pagination: z.object({
|
||||
currentPage: z.coerce.number(),
|
||||
totalPages: z.coerce.number(),
|
||||
count: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const $url = new URL(url);
|
||||
$url.search = query.toString();
|
||||
|
||||
const fetchResult = _doZodFetch(offsetLimitPageSchema, $url.href, requestInit, options);
|
||||
|
||||
return new OffsetLimitPagePromise(
|
||||
fetchResult as Promise<ZodFetchResult<OffsetLimitPageResponse<z.output<TItemSchema>>>>,
|
||||
schema,
|
||||
url,
|
||||
params,
|
||||
requestInit,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
type ZodFetchResult<T> = {
|
||||
data: T;
|
||||
response: Response;
|
||||
};
|
||||
|
||||
type PromiseOrValue<T> = T | Promise<T>;
|
||||
|
||||
async function traceZodFetch<T>(
|
||||
params: {
|
||||
url: string;
|
||||
requestInit?: RequestInit;
|
||||
options?: ZodFetchOptions;
|
||||
},
|
||||
callback: (span?: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!params.options?.tracer) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
const url = new URL(params.url);
|
||||
const method = params.requestInit?.method ?? "GET";
|
||||
const name = params.options.name ?? `${method} ${url.pathname}`;
|
||||
|
||||
return await params.options.tracer.startActiveSpan(
|
||||
name,
|
||||
async (span) => {
|
||||
return await callback(span);
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: params.options?.icon ?? "api",
|
||||
...params.options.attributes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function _doZodFetch<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
schema: TResponseBodySchema,
|
||||
url: string,
|
||||
requestInit?: PromiseOrValue<RequestInit>,
|
||||
options?: ZodFetchOptions<z.output<TResponseBodySchema>>
|
||||
): Promise<ZodFetchResult<z.output<TResponseBodySchema>>> {
|
||||
let $requestInit = await requestInit;
|
||||
|
||||
return traceZodFetch({ url, requestInit: $requestInit, options }, async (span) => {
|
||||
const requestIdempotencyKey = await randomUUID();
|
||||
|
||||
$requestInit = injectPropagationHeadersIfInWorker($requestInit);
|
||||
$requestInit = injectRequestIdempotencyKey(requestIdempotencyKey, $requestInit);
|
||||
|
||||
const result = await _doZodFetchWithRetries(schema, url, $requestInit, options);
|
||||
|
||||
if (options?.onResponseBody && span) {
|
||||
options.onResponseBody(result.data, span);
|
||||
}
|
||||
|
||||
if (options?.prepareData) {
|
||||
result.data = await options.prepareData(result.data, result.response);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function _doZodFetchWithRetries<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
schema: TResponseBodySchema,
|
||||
url: string,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions,
|
||||
attempt = 1
|
||||
): Promise<ZodFetchResult<z.output<TResponseBodySchema>>> {
|
||||
try {
|
||||
const response = await context.with(suppressTracing(context.active()), () =>
|
||||
fetch(url, requestInitWithCache(requestInit))
|
||||
);
|
||||
|
||||
const responseHeaders = createResponseHeaders(response.headers);
|
||||
|
||||
if (!response.ok) {
|
||||
const retryResult = shouldRetry(response, attempt, options?.retry);
|
||||
|
||||
if (retryResult.retry) {
|
||||
await waitForRetry(url, attempt + 1, retryResult.delay, options, requestInit, response);
|
||||
|
||||
return await _doZodFetchWithRetries(schema, url, requestInit, options, attempt + 1);
|
||||
} else {
|
||||
const errText = await response.text().catch((e) => castToError(e).message);
|
||||
const errJSON = safeJsonParse(errText);
|
||||
const errMessage = errJSON ? undefined : errText;
|
||||
|
||||
throw ApiError.generate(response.status, errJSON, errMessage, responseHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonBody = await safeJsonFromResponse(response);
|
||||
const parsedResult = schema.safeParse(jsonBody);
|
||||
|
||||
if (parsedResult.success) {
|
||||
return { data: parsedResult.data, response };
|
||||
}
|
||||
|
||||
const validationError = fromZodError(parsedResult.error);
|
||||
|
||||
throw new ApiSchemaValidationError({
|
||||
status: response.status,
|
||||
cause: validationError,
|
||||
message: validationError.message,
|
||||
rawBody: jsonBody,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof ValidationError) {
|
||||
}
|
||||
|
||||
if (options?.retry) {
|
||||
const retry = { ...defaultRetryOptions, ...options.retry };
|
||||
|
||||
const delay = calculateNextRetryDelay(retry, attempt);
|
||||
|
||||
if (delay) {
|
||||
await waitForRetry(url, attempt + 1, delay, options, requestInit);
|
||||
|
||||
return await _doZodFetchWithRetries(schema, url, requestInit, options, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiConnectionError({ cause: castToError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJsonFromResponse(response: Response): Promise<any> {
|
||||
try {
|
||||
return await response.clone().json();
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function castToError(err: any): Error {
|
||||
if (err instanceof Error) return err;
|
||||
return new Error(err);
|
||||
}
|
||||
|
||||
type ShouldRetryResult =
|
||||
| {
|
||||
retry: false;
|
||||
}
|
||||
| {
|
||||
retry: true;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
function shouldRetry(
|
||||
response: Response,
|
||||
attempt: number,
|
||||
retryOptions?: RetryOptions
|
||||
): ShouldRetryResult {
|
||||
function shouldRetryForOptions(): ShouldRetryResult {
|
||||
const retry = { ...defaultRetryOptions, ...retryOptions };
|
||||
|
||||
const delay = calculateNextRetryDelay(retry, attempt);
|
||||
|
||||
if (delay) {
|
||||
return { retry: true, delay };
|
||||
} else {
|
||||
return { retry: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Note this is not a standard header.
|
||||
const shouldRetryHeader = response.headers.get("x-should-retry");
|
||||
|
||||
// If the server explicitly says whether or not to retry, obey.
|
||||
if (shouldRetryHeader === "true") return shouldRetryForOptions();
|
||||
if (shouldRetryHeader === "false") return { retry: false };
|
||||
|
||||
// Retry on request timeouts.
|
||||
if (response.status === 408) return shouldRetryForOptions();
|
||||
|
||||
// Retry on lock timeouts.
|
||||
if (response.status === 409) return shouldRetryForOptions();
|
||||
|
||||
// Retry on rate limits.
|
||||
if (response.status === 429) {
|
||||
if (
|
||||
attempt >= (typeof retryOptions?.maxAttempts === "number" ? retryOptions?.maxAttempts : 3)
|
||||
) {
|
||||
return { retry: false };
|
||||
}
|
||||
|
||||
// x-ratelimit-reset is the unix timestamp in milliseconds when the rate limit will reset.
|
||||
const resetAtUnixEpochMs = response.headers.get("x-ratelimit-reset");
|
||||
|
||||
if (resetAtUnixEpochMs) {
|
||||
const resetAtUnixEpoch = parseInt(resetAtUnixEpochMs, 10);
|
||||
const delay = resetAtUnixEpoch - Date.now() + Math.floor(Math.random() * 1000);
|
||||
|
||||
if (delay > 0) {
|
||||
return { retry: true, delay };
|
||||
}
|
||||
}
|
||||
|
||||
return shouldRetryForOptions();
|
||||
}
|
||||
|
||||
// Retry internal errors.
|
||||
if (response.status >= 500) return shouldRetryForOptions();
|
||||
|
||||
return { retry: false };
|
||||
}
|
||||
|
||||
function safeJsonParse(text: string): any {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createResponseHeaders(headers: Response["headers"]): Record<string, string> {
|
||||
return new Proxy(
|
||||
Object.fromEntries(
|
||||
// @ts-ignore
|
||||
headers.entries()
|
||||
),
|
||||
{
|
||||
get(target, name) {
|
||||
const key = name.toString();
|
||||
return target[key.toLowerCase()] || target[key];
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function requestInitWithCache(requestInit?: RequestInit): RequestInit {
|
||||
try {
|
||||
const withCache: RequestInit = {
|
||||
...requestInit,
|
||||
cache: "no-cache",
|
||||
};
|
||||
|
||||
const _ = new Request("http://localhost", withCache);
|
||||
|
||||
return withCache;
|
||||
} catch (_error) {
|
||||
return requestInit ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subclass of `Promise` providing additional helper methods
|
||||
* for interacting with the SDK.
|
||||
*/
|
||||
export class ApiPromise<T> extends Promise<T> {
|
||||
constructor(private responsePromise: Promise<ZodFetchResult<T>>) {
|
||||
super((resolve) => {
|
||||
// this is maybe a bit weird but this has to be a no-op to not implicitly
|
||||
// parse the response body; instead .then, .catch, .finally are overridden
|
||||
// to parse the response
|
||||
resolve(null as any);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw `Response` instance instead of parsing the response
|
||||
* data.
|
||||
*
|
||||
* If you want to parse the response body but still get the `Response`
|
||||
* instance, you can use {@link withResponse()}.
|
||||
*/
|
||||
asResponse(): Promise<Response> {
|
||||
return this.responsePromise.then((p) => p.response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parsed response data and the raw `Response` instance.
|
||||
*
|
||||
* If you just want to get the raw `Response` instance without parsing it,
|
||||
* you can use {@link asResponse()}.
|
||||
*/
|
||||
async withResponse(): Promise<{ data: T; response: Response }> {
|
||||
const [data, response] = await Promise.all([this.parse(), this.asResponse()]);
|
||||
return { data, response };
|
||||
}
|
||||
|
||||
private parse(): Promise<T> {
|
||||
return this.responsePromise.then((result) => result.data);
|
||||
}
|
||||
|
||||
override then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.parse().then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
override catch<TResult = never>(
|
||||
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
|
||||
): Promise<T | TResult> {
|
||||
return this.parse().catch(onrejected);
|
||||
}
|
||||
|
||||
override finally(onfinally?: (() => void) | undefined | null): Promise<T> {
|
||||
return this.parse().finally(onfinally);
|
||||
}
|
||||
}
|
||||
|
||||
export class CursorPagePromise<TItemSchema extends z.ZodTypeAny>
|
||||
extends ApiPromise<CursorPage<z.output<TItemSchema>>>
|
||||
implements AsyncIterable<z.output<TItemSchema>>
|
||||
{
|
||||
constructor(
|
||||
result: Promise<ZodFetchResult<CursorPageResponse<z.output<TItemSchema>>>>,
|
||||
private schema: TItemSchema,
|
||||
private url: string,
|
||||
private params: FetchCursorPageParams,
|
||||
private requestInit?: RequestInit,
|
||||
private options?: ZodFetchOptions
|
||||
) {
|
||||
super(
|
||||
result.then((result) => ({
|
||||
data: new CursorPage(result.data.data, result.data.pagination, this.#fetchPage.bind(this)),
|
||||
response: result.response,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#fetchPage(params: Omit<CursorPageParams, "limit">): Promise<CursorPage<z.output<TItemSchema>>> {
|
||||
return zodfetchCursorPage(
|
||||
this.schema,
|
||||
this.url,
|
||||
{ ...this.params, ...params },
|
||||
this.requestInit,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow auto-paginating iteration on an unawaited list call, eg:
|
||||
*
|
||||
* for await (const item of client.items.list()) {
|
||||
* console.log(item)
|
||||
* }
|
||||
*/
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const page = await this;
|
||||
for await (const item of page) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OffsetLimitPagePromise<TItemSchema extends z.ZodTypeAny>
|
||||
extends ApiPromise<OffsetLimitPage<z.output<TItemSchema>>>
|
||||
implements AsyncIterable<z.output<TItemSchema>>
|
||||
{
|
||||
constructor(
|
||||
result: Promise<ZodFetchResult<OffsetLimitPageResponse<z.output<TItemSchema>>>>,
|
||||
private schema: TItemSchema,
|
||||
private url: string,
|
||||
private params: FetchOffsetLimitPageParams,
|
||||
private requestInit?: RequestInit,
|
||||
private options?: ZodFetchOptions
|
||||
) {
|
||||
super(
|
||||
result.then((result) => ({
|
||||
data: new OffsetLimitPage(
|
||||
result.data.data,
|
||||
result.data.pagination,
|
||||
this.#fetchPage.bind(this)
|
||||
),
|
||||
response: result.response,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#fetchPage(
|
||||
params: Omit<FetchOffsetLimitPageParams, "limit">
|
||||
): Promise<OffsetLimitPage<z.output<TItemSchema>>> {
|
||||
return zodfetchOffsetLimitPage(
|
||||
this.schema,
|
||||
this.url,
|
||||
{ ...this.params, ...params },
|
||||
this.requestInit,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow auto-paginating iteration on an unawaited list call, eg:
|
||||
*
|
||||
* for await (const item of client.items.list()) {
|
||||
* console.log(item)
|
||||
* }
|
||||
*/
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const page = await this;
|
||||
for await (const item of page) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRetry(
|
||||
url: string,
|
||||
attempt: number,
|
||||
delay: number,
|
||||
options?: ZodFetchOptions,
|
||||
requestInit?: RequestInit,
|
||||
response?: Response
|
||||
): Promise<void> {
|
||||
if (options?.tracer) {
|
||||
const method = requestInit?.method ?? "GET";
|
||||
|
||||
return options.tracer.startActiveSpan(
|
||||
response ? `wait after ${response.status}` : `wait after error`,
|
||||
async (span) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "wait",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `retrying ${options?.name ?? method.toUpperCase()} in ${delay}ms`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/34491287
|
||||
export function isEmptyObj(obj: object | null | undefined): boolean {
|
||||
if (!obj) return true;
|
||||
for (const _k in obj) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// https://eslint.org/docs/latest/rules/no-prototype-builtins
|
||||
export function hasOwn(obj: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
// If the requestInit has a header x-trigger-worker = true, then we will do
|
||||
// propagation.inject(context.active(), headers);
|
||||
// and return the new requestInit.
|
||||
function injectPropagationHeadersIfInWorker(requestInit?: RequestInit): RequestInit | undefined {
|
||||
const headers = new Headers(requestInit?.headers);
|
||||
|
||||
const headersObject = Object.fromEntries(headers.entries());
|
||||
|
||||
propagation.inject(context.active(), headersObject);
|
||||
|
||||
return {
|
||||
...requestInit,
|
||||
headers: new Headers(headersObject),
|
||||
};
|
||||
}
|
||||
|
||||
function injectRequestIdempotencyKey(
|
||||
requestIdempotencyKey: string,
|
||||
requestInit?: RequestInit
|
||||
): RequestInit | undefined {
|
||||
const headers = new Headers(requestInit?.headers);
|
||||
|
||||
headers.set("x-trigger-request-idempotency-key", requestIdempotencyKey);
|
||||
|
||||
return {
|
||||
...requestInit,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
export type ZodFetchSSEMessageValueSchema<
|
||||
TDiscriminatedUnion extends z.ZodDiscriminatedUnion<any, any>,
|
||||
> = z.ZodFirstPartySchemaTypes | TDiscriminatedUnion;
|
||||
|
||||
export interface ZodFetchSSEMessageCatalogSchema {
|
||||
[key: string]: ZodFetchSSEMessageValueSchema<any>;
|
||||
}
|
||||
|
||||
export type ZodFetchSSEMessageHandlers<TCatalogSchema extends ZodFetchSSEMessageCatalogSchema> =
|
||||
Partial<{
|
||||
[K in keyof TCatalogSchema]: (payload: z.infer<TCatalogSchema[K]>) => Promise<void> | void;
|
||||
}>;
|
||||
|
||||
export type ZodFetchSSEOptions<TMessageCatalog extends ZodFetchSSEMessageCatalogSchema> = {
|
||||
url: string;
|
||||
request?: RequestInit;
|
||||
messages: TMessageCatalog;
|
||||
retry?: RetryOptions;
|
||||
};
|
||||
|
||||
export class ZodFetchSSEResult<TMessageCatalog extends ZodFetchSSEMessageCatalogSchema> {
|
||||
private _eventSource: EventSource;
|
||||
|
||||
constructor(private options: ZodFetchSSEOptions<TMessageCatalog>) {
|
||||
this._eventSource = new EventSource(options.url, {
|
||||
fetch: (input, init) => {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
...options.request,
|
||||
headers: {
|
||||
...options.request?.headers,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public onConnectionError(handler: (error: ErrorEvent) => void) {
|
||||
this._eventSource.onerror = handler;
|
||||
}
|
||||
|
||||
public onMessage<T extends keyof TMessageCatalog>(
|
||||
type: T,
|
||||
handler: ZodFetchSSEMessageHandlers<TMessageCatalog>[T]
|
||||
) {
|
||||
this._eventSource.addEventListener(type as string, (event) => {
|
||||
const payload = safeJsonParse(event.data);
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = this.options.messages[type];
|
||||
|
||||
const result = schema.safeParse(payload);
|
||||
|
||||
if (result.success) {
|
||||
handler?.(result.data);
|
||||
} else {
|
||||
console.error(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this._eventSource.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function zodfetchSSE<TMessageCatalog extends ZodFetchSSEMessageCatalogSchema>(
|
||||
options: ZodFetchSSEOptions<TMessageCatalog>
|
||||
): ZodFetchSSEResult<TMessageCatalog> {
|
||||
return new ZodFetchSSEResult(options);
|
||||
}
|
||||
|
||||
export type ApiResult<TSuccessResult> =
|
||||
| { success: true; data: TSuccessResult }
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function wrapZodFetch<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
url: string,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions<z.output<T>>
|
||||
): Promise<ApiResult<z.infer<T>>> {
|
||||
try {
|
||||
const response = await zodfetch(schema, url, requestInit, {
|
||||
retry: {
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 5000,
|
||||
maxAttempts: 5,
|
||||
factor: 2,
|
||||
randomize: false,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
} else if (error instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
export type APIHeaders = Record<string, string | null | undefined>;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number | undefined;
|
||||
readonly headers: APIHeaders | undefined;
|
||||
readonly error: object | undefined;
|
||||
|
||||
readonly code: string | null | undefined;
|
||||
readonly param: string | null | undefined;
|
||||
readonly type: string | undefined;
|
||||
|
||||
constructor(
|
||||
status: number | undefined,
|
||||
error: object | undefined,
|
||||
message: string | undefined,
|
||||
headers: APIHeaders | undefined
|
||||
) {
|
||||
super(`${ApiError.makeMessage(status, error, message)}`);
|
||||
this.name = "TriggerApiError";
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
|
||||
const data = error as Record<string, any>;
|
||||
this.error = data;
|
||||
this.code = data?.["code"];
|
||||
this.param = data?.["param"];
|
||||
this.type = data?.["type"];
|
||||
}
|
||||
|
||||
private static makeMessage(status: number | undefined, error: any, message: string | undefined) {
|
||||
const errorMessage = error?.message
|
||||
? typeof error.message === "string"
|
||||
? error.message
|
||||
: JSON.stringify(error.message)
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: error
|
||||
? JSON.stringify(error)
|
||||
: undefined;
|
||||
|
||||
if (errorMessage) {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
if (status && message) {
|
||||
return `${status} ${message}`;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
return `${status} status code (no body)`;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return "(no status code or body)";
|
||||
}
|
||||
|
||||
static generate(
|
||||
status: number | undefined,
|
||||
errorResponse: object | undefined,
|
||||
message: string | undefined,
|
||||
headers: APIHeaders | undefined
|
||||
) {
|
||||
if (!status) {
|
||||
return new ApiConnectionError({ cause: castToError(errorResponse) });
|
||||
}
|
||||
|
||||
const error = (errorResponse as Record<string, any>)?.["error"];
|
||||
|
||||
if (status === 400) {
|
||||
return new BadRequestError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
return new AuthenticationError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return new PermissionDeniedError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return new NotFoundError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
return new ConflictError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 422) {
|
||||
return new UnprocessableEntityError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return new RateLimitError(status, error, message, headers);
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return new InternalServerError(status, error, message, headers);
|
||||
}
|
||||
|
||||
return new ApiError(status, error, message, headers);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiConnectionError extends ApiError {
|
||||
override readonly status: undefined = undefined;
|
||||
|
||||
constructor({ message, cause }: { message?: string; cause?: Error | undefined }) {
|
||||
super(undefined, undefined, message || "Connection error.", undefined);
|
||||
// in some environments the 'cause' property is already declared
|
||||
// @ts-ignore
|
||||
if (cause) this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends ApiError {
|
||||
override readonly status = 400 as const;
|
||||
}
|
||||
|
||||
export class AuthenticationError extends ApiError {
|
||||
override readonly status = 401 as const;
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApiError {
|
||||
override readonly status = 403 as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `error` is a 401/403 from the Trigger API (e.g. expired run-scoped PAT on realtime streams).
|
||||
* Uses structural checks so it works even if multiple copies of `@trigger.dev/core` are bundled (subclass `instanceof` can fail).
|
||||
*/
|
||||
export function isTriggerRealtimeAuthError(error: unknown): boolean {
|
||||
if (error === null || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const e = error as ApiError;
|
||||
return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApiError {
|
||||
override readonly status = 404 as const;
|
||||
}
|
||||
|
||||
export class ConflictError extends ApiError {
|
||||
override readonly status = 409 as const;
|
||||
}
|
||||
|
||||
export class UnprocessableEntityError extends ApiError {
|
||||
override readonly status = 422 as const;
|
||||
}
|
||||
|
||||
export class RateLimitError extends ApiError {
|
||||
override readonly status = 429 as const;
|
||||
|
||||
get millisecondsUntilReset(): number | undefined {
|
||||
// x-ratelimit-reset is the unix timestamp in milliseconds when the rate limit will reset.
|
||||
const resetAtUnixEpochMs = (this.headers ?? {})["x-ratelimit-reset"];
|
||||
|
||||
if (typeof resetAtUnixEpochMs === "string") {
|
||||
const resetAtUnixEpoch = parseInt(resetAtUnixEpochMs, 10);
|
||||
|
||||
if (isNaN(resetAtUnixEpoch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add between 0 and 2000ms to the reset time to add jitter
|
||||
return Math.max(resetAtUnixEpoch - Date.now() + Math.floor(Math.random() * 2000), 0);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends ApiError {}
|
||||
|
||||
export class ApiSchemaValidationError extends ApiError {
|
||||
override readonly status = 200 as const;
|
||||
readonly rawBody: any;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
cause,
|
||||
status,
|
||||
rawBody,
|
||||
headers,
|
||||
}: {
|
||||
message?: string;
|
||||
cause?: Error | undefined;
|
||||
status: number;
|
||||
rawBody: any;
|
||||
headers: APIHeaders | undefined;
|
||||
}) {
|
||||
super(status, undefined, message || "Validation error.", headers);
|
||||
// in some environments the 'cause' property is already declared
|
||||
// @ts-ignore
|
||||
if (cause) this.cause = cause;
|
||||
|
||||
this.rawBody = rawBody;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a batch stream completes but the batch was not sealed.
|
||||
* This indicates that not all expected items were received by the server.
|
||||
* The client should retry sending all items, or investigate the mismatch.
|
||||
*/
|
||||
export class BatchNotSealedError extends Error {
|
||||
readonly name = "BatchNotSealedError";
|
||||
|
||||
/** The batch ID that was not sealed */
|
||||
readonly batchId: string;
|
||||
|
||||
/** Number of items currently enqueued on the server */
|
||||
readonly enqueuedCount: number;
|
||||
|
||||
/** Number of items expected to complete the batch */
|
||||
readonly expectedCount: number;
|
||||
|
||||
/** Number of items accepted in this request */
|
||||
readonly itemsAccepted: number;
|
||||
|
||||
/** Number of items deduplicated in this request */
|
||||
readonly itemsDeduplicated: number;
|
||||
|
||||
constructor(options: {
|
||||
batchId: string;
|
||||
enqueuedCount: number;
|
||||
expectedCount: number;
|
||||
itemsAccepted: number;
|
||||
itemsDeduplicated: number;
|
||||
}) {
|
||||
const message = `Batch ${options.batchId} was not sealed: received ${options.enqueuedCount} of ${options.expectedCount} expected items (accepted: ${options.itemsAccepted}, deduplicated: ${options.itemsDeduplicated})`;
|
||||
super(message);
|
||||
|
||||
this.batchId = options.batchId;
|
||||
this.enqueuedCount = options.enqueuedCount;
|
||||
this.expectedCount = options.expectedCount;
|
||||
this.itemsAccepted = options.itemsAccepted;
|
||||
this.itemsDeduplicated = options.itemsDeduplicated;
|
||||
}
|
||||
}
|
||||
|
||||
function castToError(err: any): Error {
|
||||
if (err instanceof Error) return err;
|
||||
return new Error(err);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { GitMeta } from "../schemas/index.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
import { isDefaultDevBranch } from "../utils/gitBranch.js";
|
||||
|
||||
export function getBranch({
|
||||
specified,
|
||||
gitMeta,
|
||||
}: {
|
||||
specified?: string;
|
||||
gitMeta?: GitMeta;
|
||||
}): string | undefined {
|
||||
if (specified) {
|
||||
return specified;
|
||||
}
|
||||
|
||||
// not specified, so detect our variable from process.env
|
||||
const envVar = getEnvVar("TRIGGER_PREVIEW_BRANCH");
|
||||
if (envVar) {
|
||||
return envVar;
|
||||
}
|
||||
|
||||
// detect the Vercel preview branch
|
||||
const vercelPreviewBranch = getEnvVar("VERCEL_GIT_COMMIT_REF");
|
||||
if (vercelPreviewBranch) {
|
||||
return vercelPreviewBranch;
|
||||
}
|
||||
|
||||
// not specified, so detect from git metadata
|
||||
if (gitMeta?.commitRef) {
|
||||
return gitMeta.commitRef;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDevBranch({ specified }: { specified?: string }): string | undefined {
|
||||
// For development we don't look at git/Vercel — only the flag and our env var.
|
||||
const branch = specified ?? getEnvVar("TRIGGER_DEV_BRANCH");
|
||||
|
||||
// No branch and the "default" sentinel both mean the root dev env, which
|
||||
// carries no branch. Collapse to undefined so callers send no branch
|
||||
if (!branch || isDefaultDevBranch(branch)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
export interface CursorPageParams {
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
|
||||
export interface OffsetLimitPageParams {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface PageResponse<Item> {
|
||||
data: Array<Item>;
|
||||
}
|
||||
|
||||
export interface CursorPageResponse<Item> extends PageResponse<Item> {
|
||||
pagination: {
|
||||
next?: string;
|
||||
previous?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OffsetLimitPageResponse<Item> extends PageResponse<Item> {
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Page<Item> {
|
||||
getPaginatedItems(): Item[];
|
||||
hasNextPage(): boolean;
|
||||
hasPreviousPage(): boolean;
|
||||
}
|
||||
|
||||
export class CursorPage<Item> implements CursorPageResponse<Item>, Page<Item>, AsyncIterable<Item> {
|
||||
data: Array<Item>;
|
||||
pagination: { next?: string; previous?: string };
|
||||
|
||||
constructor(
|
||||
data: Array<Item>,
|
||||
pagination: { next?: string; previous?: string },
|
||||
private pageFetcher: (params: Omit<CursorPageParams, "limit">) => Promise<CursorPage<Item>>
|
||||
) {
|
||||
this.data = data;
|
||||
this.pagination = pagination;
|
||||
}
|
||||
|
||||
getPaginatedItems(): Item[] {
|
||||
return this.data ?? [];
|
||||
}
|
||||
|
||||
hasNextPage(): boolean {
|
||||
return !!this.pagination.next;
|
||||
}
|
||||
|
||||
hasPreviousPage(): boolean {
|
||||
return !!this.pagination.previous;
|
||||
}
|
||||
|
||||
getNextPage(): Promise<CursorPage<Item>> {
|
||||
if (!this.pagination.next) {
|
||||
throw new Error("No next page available");
|
||||
}
|
||||
|
||||
return this.pageFetcher({ after: this.pagination.next });
|
||||
}
|
||||
|
||||
getPreviousPage(): Promise<CursorPage<Item>> {
|
||||
if (!this.pagination.previous) {
|
||||
throw new Error("No previous page available");
|
||||
}
|
||||
|
||||
return this.pageFetcher({ before: this.pagination.previous });
|
||||
}
|
||||
|
||||
async *iterPages() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
let page: CursorPage<Item> = this;
|
||||
yield page;
|
||||
while (page.hasNextPage()) {
|
||||
page = await page.getNextPage();
|
||||
yield page;
|
||||
}
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for await (const page of this.iterPages()) {
|
||||
for (const item of page.getPaginatedItems()) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OffsetLimitPage<Item>
|
||||
implements OffsetLimitPageResponse<Item>, Page<Item>, AsyncIterable<Item>
|
||||
{
|
||||
data: Array<Item>;
|
||||
pagination: { currentPage: number; totalPages: number; count: number };
|
||||
|
||||
constructor(
|
||||
data: Array<Item>,
|
||||
pagination: { currentPage: number; totalPages: number; count: number },
|
||||
private pageFetcher: (
|
||||
params: Omit<OffsetLimitPageParams, "limit">
|
||||
) => Promise<OffsetLimitPage<Item>>
|
||||
) {
|
||||
this.data = data;
|
||||
this.pagination = pagination;
|
||||
}
|
||||
|
||||
getPaginatedItems(): Item[] {
|
||||
return this.data ?? [];
|
||||
}
|
||||
|
||||
hasNextPage(): boolean {
|
||||
return this.pagination.currentPage < this.pagination.totalPages;
|
||||
}
|
||||
|
||||
hasPreviousPage(): boolean {
|
||||
return this.pagination.currentPage > 1;
|
||||
}
|
||||
|
||||
getNextPage(): Promise<OffsetLimitPage<Item>> {
|
||||
if (!this.hasNextPage()) {
|
||||
throw new Error("No next page available");
|
||||
}
|
||||
|
||||
return this.pageFetcher({
|
||||
page: this.pagination.currentPage + 1,
|
||||
});
|
||||
}
|
||||
|
||||
getPreviousPage(): Promise<OffsetLimitPage<Item>> {
|
||||
if (!this.hasPreviousPage()) {
|
||||
throw new Error("No previous page available");
|
||||
}
|
||||
|
||||
return this.pageFetcher({
|
||||
page: this.pagination.currentPage - 1,
|
||||
});
|
||||
}
|
||||
|
||||
async *iterPages() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
let page: OffsetLimitPage<Item> = this;
|
||||
yield page;
|
||||
while (page.hasNextPage()) {
|
||||
page = await page.getNextPage();
|
||||
yield page;
|
||||
}
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for await (const page of this.iterPages()) {
|
||||
for (const item of page.getPaginatedItems()) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SSEStreamSubscription } from "./runStream.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
describe("SSEStreamSubscription retry behavior", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// A response.body that emits one SSE event then closes, so each
|
||||
// successful subscribe() exits cleanly via reader.read() done=true
|
||||
// and the test doesn't hang reading from a long-lived stream.
|
||||
function makeSSEResponse() {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`id: 1\ndata: {"hello":1}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
|
||||
});
|
||||
}
|
||||
|
||||
// Drain a ReadableStream<SSEStreamPart> until it closes or errors.
|
||||
// Returns received chunks plus terminal state.
|
||||
async function drain(stream: ReadableStream<{ id: string; chunk: unknown }>) {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Array<{ id: string; chunk: unknown }> = [];
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return { chunks, error: undefined as Error | undefined };
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch (e) {
|
||||
return { chunks, error: e as Error };
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("retries past the legacy 5-attempt cap", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
if (attempts < 8) {
|
||||
throw new TypeError("fetch failed (simulated network drop)");
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
// Compress the timing for the test — defaults are 100ms initial,
|
||||
// 5s cap, retry forever; here we want fast iteration.
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
});
|
||||
|
||||
const stream = await sub.subscribe();
|
||||
const result = await drain(stream);
|
||||
|
||||
expect(attempts).toBe(8);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.chunks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("caps the exponential backoff at maxRetryDelayMs", async () => {
|
||||
let attempts = 0;
|
||||
const callTimes: number[] = [];
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
callTimes.push(Date.now());
|
||||
attempts++;
|
||||
if (attempts < 6) {
|
||||
throw new TypeError("fetch failed");
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 10,
|
||||
maxRetryDelayMs: 30,
|
||||
});
|
||||
|
||||
const stream = await sub.subscribe();
|
||||
await drain(stream);
|
||||
|
||||
expect(attempts).toBe(6);
|
||||
|
||||
// Without the cap, backoff would be 10, 20, 40, 80, 160 (= 310ms total).
|
||||
// With cap=30, it's 10, 20, 30, 30, 30 (= 120ms total). Allow generous
|
||||
// slack for setTimeout jitter; the assertion is "well under uncapped".
|
||||
const totalElapsed = callTimes.at(-1)! - callTimes[0]!;
|
||||
expect(totalElapsed).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it("retryNow() wakes an in-flight backoff and reconnects immediately", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
throw new TypeError("fetch failed");
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
// Backoff is intentionally long. retryNow() should short-circuit it.
|
||||
retryDelayMs: 5_000,
|
||||
maxRetryDelayMs: 5_000,
|
||||
});
|
||||
|
||||
const subscribePromise = sub.subscribe().then(drain);
|
||||
|
||||
// Wait for the first attempt to fail and the backoff to start.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
sub.retryNow();
|
||||
|
||||
const start = Date.now();
|
||||
const result = await subscribePromise;
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error).toBeUndefined();
|
||||
// Without retryNow this would have waited ~5000ms; with it, the
|
||||
// second attempt fires nearly immediately after the first failure.
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("respects abort signal during retry backoff", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
signal: ac.signal,
|
||||
retryDelayMs: 1_000,
|
||||
maxRetryDelayMs: 1_000,
|
||||
});
|
||||
|
||||
const subscribePromise = sub.subscribe().then(drain);
|
||||
|
||||
// Let the first attempt fail and enter backoff, then abort.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
ac.abort();
|
||||
|
||||
const result = await subscribePromise;
|
||||
expect(result.error).toBeUndefined();
|
||||
// Abort should stop retries; we should have made at most a couple
|
||||
// of attempts before the abort took effect.
|
||||
expect(attempts).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("forceReconnect mid-read drops the stream and resumes with Last-Event-ID", async () => {
|
||||
let attempts = 0;
|
||||
const seenLastEventIds: Array<string | null> = [];
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
attempts++;
|
||||
const lastEventIdHeader = (init?.headers as Record<string, string> | undefined)?.[
|
||||
"Last-Event-ID"
|
||||
];
|
||||
seenLastEventIds.push(lastEventIdHeader ?? null);
|
||||
|
||||
if (attempts === 1) {
|
||||
// Headers arrive immediately, body emits one chunk then hangs
|
||||
// until aborted. The test calls forceReconnect after seeing
|
||||
// the chunk, which should drop this stream and trigger a
|
||||
// resume request with Last-Event-ID set.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`id: 7\ndata: {"first":true}\n\n`));
|
||||
init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
|
||||
});
|
||||
}
|
||||
// Second attempt: emit a second chunk and close cleanly.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`id: 8\ndata: {"second":true}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
|
||||
});
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
fetchTimeoutMs: 60_000,
|
||||
});
|
||||
|
||||
const stream = await sub.subscribe();
|
||||
const reader = stream.getReader();
|
||||
|
||||
// Read the first chunk, then force-reconnect mid-stream.
|
||||
const first = await reader.read();
|
||||
expect(first.done).toBe(false);
|
||||
expect((first.value!.chunk as { first?: boolean }).first).toBe(true);
|
||||
|
||||
sub.forceReconnect();
|
||||
|
||||
// Second chunk arrives from the resumed connection.
|
||||
const second = await reader.read();
|
||||
expect(second.done).toBe(false);
|
||||
expect((second.value!.chunk as { second?: boolean }).second).toBe(true);
|
||||
|
||||
const tail = await reader.read();
|
||||
expect(tail.done).toBe(true);
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
expect(seenLastEventIds[0]).toBeNull();
|
||||
// Resumed request includes the Last-Event-ID from the first chunk.
|
||||
expect(seenLastEventIds[1]).toBe("7");
|
||||
});
|
||||
|
||||
it("forceReconnect aborts the in-flight fetch and retries", async () => {
|
||||
let attempts = 0;
|
||||
let firstResolve: (() => void) | undefined;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
// Hang the first attempt forever (or until signal aborts).
|
||||
// forceReconnect should make this attempt's signal abort and
|
||||
// throw, taking us into the retry path.
|
||||
return new Promise((resolve, reject) => {
|
||||
firstResolve = () => resolve(makeSSEResponse());
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
// Long fetch timeout so it doesn't fire instead of forceReconnect.
|
||||
fetchTimeoutMs: 60_000,
|
||||
});
|
||||
|
||||
const subscribePromise = sub.subscribe().then(drain);
|
||||
|
||||
// Let the first fetch hang, then force reconnect.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
sub.forceReconnect();
|
||||
|
||||
const result = await subscribePromise;
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.chunks).toHaveLength(1);
|
||||
// Sanity: the hung first fetch was abandoned, never resolved.
|
||||
expect(firstResolve).toBeDefined();
|
||||
});
|
||||
|
||||
it("aborts a slow fetch via fetchTimeoutMs and retries", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
// Hang until aborted.
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("aborted", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
fetchTimeoutMs: 100,
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.chunks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("aborts a silent reader via stallTimeoutMs and retries", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
// Headers arrive immediately, but the body stream emits no
|
||||
// chunks until aborted. The stall timer should fire and
|
||||
// force a reconnect.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")));
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" },
|
||||
});
|
||||
}
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
stallTimeoutMs: 100,
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.chunks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not retry on 404 (stream gone)", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const errors: Error[] = [];
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
onError: (e) => errors.push(e),
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(1);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not retry on 410 (session closed)", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
return new Response("gone", { status: 410 });
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(1);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("respects custom nonRetryableStatuses", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
return new Response("forbidden", { status: 403 });
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
nonRetryableStatuses: [403],
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(1);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("retries on 503 (caller-tunable nonRetryableStatuses)", async () => {
|
||||
let attempts = 0;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) return new Response("unavailable", { status: 503 });
|
||||
return makeSSEResponse();
|
||||
});
|
||||
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
retryDelayMs: 1,
|
||||
maxRetryDelayMs: 5,
|
||||
// 503 is NOT in the default non-retryable set; it should retry.
|
||||
});
|
||||
|
||||
const result = await sub.subscribe().then(drain);
|
||||
expect(attempts).toBe(3);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.chunks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies jitter to backoff (delays vary across attempts)", async () => {
|
||||
const callTimes: number[] = [];
|
||||
globalThis.fetch = vi.fn().mockImplementation(async () => {
|
||||
callTimes.push(performance.now());
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
const sub = new SSEStreamSubscription("http://example.test/sse", {
|
||||
signal: ac.signal,
|
||||
retryDelayMs: 50,
|
||||
maxRetryDelayMs: 50,
|
||||
retryJitter: 0.5, // 50% — final delay in [25ms, 50ms]
|
||||
});
|
||||
|
||||
const promise = sub.subscribe().then(drain);
|
||||
await new Promise((r) => setTimeout(r, 600)); // allow ~10 attempts
|
||||
ac.abort();
|
||||
await promise;
|
||||
|
||||
expect(callTimes.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Compute inter-attempt gaps (skip the first since it has no prior).
|
||||
const gaps = callTimes.slice(1).map((t, i) => t - callTimes[i]!);
|
||||
// Without jitter all gaps would be ~50ms. With 50% jitter they
|
||||
// should land in [~25ms, ~50ms] and not all be identical.
|
||||
const min = Math.min(...gaps);
|
||||
const max = Math.max(...gaps);
|
||||
expect(min).toBeGreaterThanOrEqual(20); // a little slack for timer scheduling
|
||||
expect(max).toBeLessThanOrEqual(80);
|
||||
// Variance check — at least one gap should differ from another by
|
||||
// a measurable amount (rules out a deterministic-delay regression).
|
||||
expect(max - min).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSEStreamSubscription v2 batch parsing — record kinds", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type ParsedPart = {
|
||||
id: string;
|
||||
chunk: unknown;
|
||||
headers?: ReadonlyArray<readonly [string, string]>;
|
||||
};
|
||||
|
||||
// Build a v2 batch SSE response with the given records and close.
|
||||
function makeBatchResponse(
|
||||
records: Array<{
|
||||
body: string;
|
||||
seq_num: number;
|
||||
timestamp: number;
|
||||
headers?: Array<[string, string]>;
|
||||
}>
|
||||
) {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`event: batch\ndata: ${JSON.stringify({ records })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
|
||||
});
|
||||
}
|
||||
|
||||
async function drain(stream: ReadableStream<ParsedPart>) {
|
||||
const reader = stream.getReader();
|
||||
const parts: ParsedPart[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
reader.releaseLock();
|
||||
return parts;
|
||||
}
|
||||
parts.push(value as ParsedPart);
|
||||
}
|
||||
}
|
||||
|
||||
it("data records flow through with headers and parsed body", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
makeBatchResponse([
|
||||
{
|
||||
body: JSON.stringify({ data: { type: "text-delta", delta: "hi" }, id: "p1" }),
|
||||
seq_num: 5,
|
||||
timestamp: 1700000000000,
|
||||
headers: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
|
||||
const parts = await sub.subscribe().then(drain);
|
||||
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]!.id).toBe("5");
|
||||
expect(parts[0]!.chunk).toEqual({ type: "text-delta", delta: "hi" });
|
||||
expect(parts[0]!.headers).toEqual([]);
|
||||
});
|
||||
|
||||
it("S2 command records (empty-name header) are filtered out", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
makeBatchResponse([
|
||||
{
|
||||
body: JSON.stringify({ data: { type: "text-delta", delta: "before" }, id: "p1" }),
|
||||
seq_num: 4,
|
||||
timestamp: 1700000000000,
|
||||
headers: [],
|
||||
},
|
||||
// Trim command record — empty-name header, opaque body.
|
||||
{
|
||||
body: "AAAAAAAAAAQ=",
|
||||
seq_num: 5,
|
||||
timestamp: 1700000000001,
|
||||
headers: [["", "trim"]],
|
||||
},
|
||||
{
|
||||
body: JSON.stringify({ data: { type: "text-delta", delta: "after" }, id: "p2" }),
|
||||
seq_num: 6,
|
||||
timestamp: 1700000000002,
|
||||
headers: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
|
||||
const parts = await sub.subscribe().then(drain);
|
||||
|
||||
// Trim record stripped — only the two data records survive.
|
||||
expect(parts).toHaveLength(2);
|
||||
expect((parts[0]!.chunk as any).delta).toBe("before");
|
||||
expect((parts[1]!.chunk as any).delta).toBe("after");
|
||||
});
|
||||
|
||||
it("trigger-control records flow with headers and undefined chunk", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
makeBatchResponse([
|
||||
{
|
||||
body: "",
|
||||
seq_num: 7,
|
||||
timestamp: 1700000000003,
|
||||
headers: [
|
||||
["trigger-control", "turn-complete"],
|
||||
["public-access-token", "eyJ..."],
|
||||
],
|
||||
},
|
||||
])
|
||||
);
|
||||
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
|
||||
const parts = await sub.subscribe().then(drain);
|
||||
|
||||
// Control record passes through so consumers can route by header,
|
||||
// but its `chunk` is undefined (empty body).
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0]!.chunk).toBeUndefined();
|
||||
expect(parts[0]!.headers).toEqual([
|
||||
["trigger-control", "turn-complete"],
|
||||
["public-access-token", "eyJ..."],
|
||||
]);
|
||||
});
|
||||
|
||||
it("malformed data record body does not crash; cursor still advances", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
makeBatchResponse([
|
||||
{
|
||||
body: "not json at all",
|
||||
seq_num: 8,
|
||||
timestamp: 1700000000004,
|
||||
headers: [],
|
||||
},
|
||||
{
|
||||
body: JSON.stringify({ data: { type: "text-delta", delta: "x" }, id: "p3" }),
|
||||
seq_num: 9,
|
||||
timestamp: 1700000000005,
|
||||
headers: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
const sub = new SSEStreamSubscription("http://x", { maxRetries: 0 });
|
||||
const parts = await sub.subscribe().then(drain);
|
||||
|
||||
// Malformed record still propagates with undefined chunk (matches
|
||||
// control-record shape); next data record is fine.
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0]!.chunk).toBeUndefined();
|
||||
expect((parts[1]!.chunk as any).delta).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,970 @@
|
||||
import type { EventSourceMessage } from "eventsource-parser/stream";
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
import type { DeserializedJson } from "../../schemas/json.js";
|
||||
import { createJsonErrorObject } from "../errors.js";
|
||||
import type { RunStatus } from "../schemas/api.js";
|
||||
import { SubscribeRunRawShape } from "../schemas/api.js";
|
||||
import type { SerializedError } from "../schemas/common.js";
|
||||
import type { AsyncIterableStream } from "../streams/asyncIterableStream.js";
|
||||
import { createAsyncIterableReadable } from "../streams/asyncIterableStream.js";
|
||||
import type { AnyRunTypes, AnyTask, InferRunTypes } from "../types/tasks.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
import type { IOPacket } from "../utils/ioSerialization.js";
|
||||
import { conditionallyImportAndParsePacket, parsePacket } from "../utils/ioSerialization.js";
|
||||
import { ApiError, isTriggerRealtimeAuthError } from "./errors.js";
|
||||
import type { ApiClient } from "./index.js";
|
||||
import { zodShapeStream } from "./stream.js";
|
||||
|
||||
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
|
||||
? {
|
||||
id: string;
|
||||
taskIdentifier: TRunTypes["taskIdentifier"];
|
||||
payload: TRunTypes["payload"];
|
||||
output?: TRunTypes["output"];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
status: RunStatus;
|
||||
durationMs: number;
|
||||
costInCents: number;
|
||||
baseCostInCents: number;
|
||||
tags: string[];
|
||||
idempotencyKey?: string;
|
||||
expiredAt?: Date;
|
||||
ttl?: string;
|
||||
finishedAt?: Date;
|
||||
startedAt?: Date;
|
||||
delayedUntil?: Date;
|
||||
queuedAt?: Date;
|
||||
metadata?: Record<string, DeserializedJson>;
|
||||
error?: SerializedError;
|
||||
isTest: boolean;
|
||||
isQueued: boolean;
|
||||
isExecuting: boolean;
|
||||
isWaiting: boolean;
|
||||
isCompleted: boolean;
|
||||
isFailed: boolean;
|
||||
isSuccess: boolean;
|
||||
isCancelled: boolean;
|
||||
realtimeStreams: string[];
|
||||
}
|
||||
: never;
|
||||
|
||||
export type AnyRunShape = RunShape<AnyRunTypes>;
|
||||
|
||||
export type TaskRunShape<TTask extends AnyTask> = RunShape<InferRunTypes<TTask>>;
|
||||
export type RealtimeRun<TTask extends AnyTask> = TaskRunShape<TTask>;
|
||||
export type AnyRealtimeRun = RealtimeRun<AnyTask>;
|
||||
|
||||
export type RealtimeRunSkipColumns = Array<
|
||||
| "startedAt"
|
||||
| "delayUntil"
|
||||
| "queuedAt"
|
||||
| "expiredAt"
|
||||
| "completedAt"
|
||||
| "number"
|
||||
| "isTest"
|
||||
| "usageDurationMs"
|
||||
| "costInCents"
|
||||
| "baseCostInCents"
|
||||
| "ttl"
|
||||
| "payload"
|
||||
| "payloadType"
|
||||
| "metadata"
|
||||
| "output"
|
||||
| "outputType"
|
||||
| "runTags"
|
||||
| "error"
|
||||
>;
|
||||
|
||||
export type RunStreamCallback<TRunTypes extends AnyRunTypes> = (
|
||||
run: RunShape<TRunTypes>
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type RunShapeStreamOptions = {
|
||||
headers?: Record<string, string>;
|
||||
fetchClient?: typeof fetch;
|
||||
closeOnComplete?: boolean;
|
||||
signal?: AbortSignal;
|
||||
client?: ApiClient;
|
||||
onFetchError?: (e: Error) => void;
|
||||
};
|
||||
|
||||
export type StreamPartResult<TRun, TStreams extends Record<string, any>> = {
|
||||
[K in keyof TStreams]: {
|
||||
type: K;
|
||||
chunk: TStreams[K];
|
||||
run: TRun;
|
||||
};
|
||||
}[keyof TStreams];
|
||||
|
||||
export type RunWithStreamsResult<TRun, TStreams extends Record<string, any>> =
|
||||
| {
|
||||
type: "run";
|
||||
run: TRun;
|
||||
}
|
||||
| StreamPartResult<TRun, TStreams>;
|
||||
|
||||
export function runShapeStream<TRunTypes extends AnyRunTypes>(
|
||||
url: string,
|
||||
options?: RunShapeStreamOptions
|
||||
): RunSubscription<TRunTypes> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const streamFactory = new SSEStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
// If the user supplied AbortSignal is aborted, we should abort the internal controller
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const runStreamInstance = zodShapeStream(SubscribeRunRawShape, url, {
|
||||
...options,
|
||||
signal: abortController.signal,
|
||||
onError: (e) => {
|
||||
options?.onFetchError?.(e);
|
||||
},
|
||||
});
|
||||
|
||||
const $options: RunSubscriptionOptions = {
|
||||
runShapeStream: runStreamInstance.stream,
|
||||
stopRunShapeStream: () => runStreamInstance.stop(30 * 1000),
|
||||
streamFactory: streamFactory,
|
||||
abortController,
|
||||
...options,
|
||||
};
|
||||
|
||||
return new RunSubscription<TRunTypes>($options);
|
||||
}
|
||||
|
||||
// First, define interfaces for the stream handling
|
||||
export interface StreamSubscription {
|
||||
subscribe(): Promise<ReadableStream<SSEStreamPart<unknown>>>;
|
||||
}
|
||||
|
||||
export type CreateStreamSubscriptionOptions = {
|
||||
baseUrl?: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeoutInSeconds?: number;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
export interface StreamSubscriptionFactory {
|
||||
createSubscription(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: CreateStreamSubscriptionOptions
|
||||
): StreamSubscription;
|
||||
}
|
||||
|
||||
export type SSEStreamPart<TChunk = unknown> = {
|
||||
id: string;
|
||||
chunk: TChunk;
|
||||
timestamp: number;
|
||||
/**
|
||||
* S2 record headers, when the underlying transport is the v2 batch shape
|
||||
* (Session streams). Undefined for v1 streams. Empty array when the record
|
||||
* had no headers. Trigger control records carry a `trigger-control` named
|
||||
* header (see `trigger-control` records on `session.out`) and may reach
|
||||
* this struct. S2 command records (trim/fence) are identified by an
|
||||
* empty-name first header and are filtered out before enqueue.
|
||||
*/
|
||||
headers?: Array<[string, string]>;
|
||||
};
|
||||
|
||||
// Real implementation for production
|
||||
export class SSEStreamSubscription implements StreamSubscription {
|
||||
private lastEventId: string | undefined;
|
||||
private retryCount = 0;
|
||||
private maxRetries: number;
|
||||
private retryDelayMs: number;
|
||||
private maxRetryDelayMs: number;
|
||||
private retryJitter: number;
|
||||
private fetchTimeoutMs: number;
|
||||
private stallTimeoutMs: number;
|
||||
private nonRetryableStatuses: ReadonlySet<number>;
|
||||
private retryNowController: AbortController | null = null;
|
||||
private internalAbort: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private options: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeoutInSeconds?: number;
|
||||
lastEventId?: string;
|
||||
// Retry knobs. Defaults: retry forever, 100ms initial backoff,
|
||||
// capped at 5s with 50% jitter. Keeps mobile clients reconnecting
|
||||
// through transient drops without giving up after a fixed window
|
||||
// and prevents thundering-herd when many clients reconnect after
|
||||
// a brief server blip.
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
retryJitter?: number;
|
||||
// Per-attempt fetch timeout — aborts the connect attempt if
|
||||
// response headers don't arrive in time. Catches stuck TCP
|
||||
// sockets where `fetch()` blocks forever waiting on a dead
|
||||
// server. Cleared once headers arrive; long-lived chunk reads
|
||||
// are governed by `stallTimeoutMs` instead.
|
||||
fetchTimeoutMs?: number;
|
||||
// Stall detector — if no chunks arrive within this window after
|
||||
// the connection is established, force a reconnect. Catches
|
||||
// silent-dead-socket cases (mobile OS killed the TCP socket but
|
||||
// the read just blocks). Disabled (`0`) by default; opt in
|
||||
// explicitly. Servers that emit periodic keepalive comments
|
||||
// reset the timer naturally.
|
||||
stallTimeoutMs?: number;
|
||||
// HTTP statuses that should NOT be retried — fail the stream
|
||||
// permanently. Defaults cover the permanent client-error set:
|
||||
// `400` (bad request), `404` (stream gone), `409` (conflict),
|
||||
// `410` (session closed), `422` (unprocessable). Tune per-caller
|
||||
// for other 4xx.
|
||||
nonRetryableStatuses?: readonly number[];
|
||||
// Optional fetch override. Used by transports that need to route
|
||||
// the SSE connect through a custom path (proxy, custom headers,
|
||||
// tracing). Defaults to global `fetch`.
|
||||
fetchClient?: typeof fetch;
|
||||
}
|
||||
) {
|
||||
this.lastEventId = options.lastEventId;
|
||||
this.maxRetries = options.maxRetries ?? Infinity;
|
||||
this.retryDelayMs = options.retryDelayMs ?? 100;
|
||||
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 5000;
|
||||
this.retryJitter = options.retryJitter ?? 0.5;
|
||||
this.fetchTimeoutMs = options.fetchTimeoutMs ?? 30_000;
|
||||
this.stallTimeoutMs = options.stallTimeoutMs ?? 0;
|
||||
this.nonRetryableStatuses = new Set(options.nonRetryableStatuses ?? [400, 404, 409, 410, 422]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake an in-flight retry backoff and reconnect immediately.
|
||||
*
|
||||
* No-op if no retry is currently waiting (i.e. we're already
|
||||
* connected and reading). Use this for cheap "hint" wakeups like
|
||||
* the `online` event or a short-hidden visibility return —
|
||||
* `forceReconnect()` is the heavier hammer.
|
||||
*/
|
||||
retryNow(): void {
|
||||
this.retryNowController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the current connection (or wake a pending backoff) and
|
||||
* reconnect.
|
||||
*
|
||||
* Use when the existing TCP socket is suspected dead but the reader
|
||||
* hasn't noticed yet — common after a mobile tab background-kill or
|
||||
* a Safari bfcache restore. Aborts the in-flight fetch / read so
|
||||
* the catch path takes us through `retryConnection` and re-fetches
|
||||
* with `Last-Event-ID`.
|
||||
*/
|
||||
forceReconnect(): void {
|
||||
this.internalAbort?.abort();
|
||||
this.retryNowController?.abort();
|
||||
}
|
||||
|
||||
async subscribe(): Promise<ReadableStream<SSEStreamPart>> {
|
||||
// eslint-disable-next-line no-this-alias
|
||||
const self = this;
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
await self.connectStream(controller);
|
||||
},
|
||||
cancel() {
|
||||
self.options.onComplete?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async connectStream(
|
||||
controller: ReadableStreamDefaultController<SSEStreamPart>
|
||||
): Promise<void> {
|
||||
// Two abort sources flow through `internalAbort.signal`:
|
||||
// - this.options.signal: caller cancel — bypass retry, exit cleanly.
|
||||
// - this.internalAbort: per-attempt force-reconnect / fetch-timeout
|
||||
// / stall-timeout — treated as a transient error, retry path runs.
|
||||
// Use `this.options.signal?.aborted` in the catch to distinguish.
|
||||
this.internalAbort = new AbortController();
|
||||
const unlinkUserAbort = linkAbort(this.options.signal, this.internalAbort);
|
||||
|
||||
// Per-attempt fetch timeout. Cleared once response headers arrive;
|
||||
// chunk-read latency is governed by `stallTimeoutMs` instead.
|
||||
const fetchTimer = setTimeout(() => this.internalAbort?.abort(), this.fetchTimeoutMs);
|
||||
|
||||
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const armStall = () => {
|
||||
if (this.stallTimeoutMs <= 0) return;
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => this.internalAbort?.abort(), this.stallTimeoutMs);
|
||||
};
|
||||
|
||||
// Idempotent — both the catch (before recursion) and the finally
|
||||
// call this. Without the catch-side call, every retry leaks an
|
||||
// abort listener on `this.options.signal` because the finally
|
||||
// doesn't run until the entire recursion unwinds.
|
||||
const cleanupAttempt = () => {
|
||||
clearTimeout(fetchTimer);
|
||||
clearTimeout(stallTimer);
|
||||
unlinkUserAbort();
|
||||
this.internalAbort = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
...this.options.headers,
|
||||
};
|
||||
if (this.lastEventId) headers["Last-Event-ID"] = this.lastEventId;
|
||||
if (this.options.timeoutInSeconds) {
|
||||
headers["Timeout-Seconds"] = this.options.timeoutInSeconds.toString();
|
||||
}
|
||||
|
||||
const fetchClient = this.options.fetchClient ?? fetch;
|
||||
const response = await fetchClient(this.url, {
|
||||
headers,
|
||||
signal: this.internalAbort.signal,
|
||||
});
|
||||
clearTimeout(fetchTimer);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = ApiError.generate(
|
||||
response.status,
|
||||
{},
|
||||
"Could not subscribe to stream",
|
||||
Object.fromEntries(response.headers)
|
||||
);
|
||||
this.options.onError?.(error);
|
||||
if (this.nonRetryableStatuses.has(response.status)) {
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const error = new Error("No response body");
|
||||
this.options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const streamVersion = response.headers.get("X-Stream-Version") ?? "v1";
|
||||
this.retryCount = 0; // reset on success
|
||||
armStall();
|
||||
|
||||
// Dedup window for record ids. Bounded with FIFO eviction so a
|
||||
// long-lived `watch: true` subscription (one connection across many
|
||||
// turns) doesn't grow this set without bound. The window only needs
|
||||
// to cover the overlap a reconnect/replay can re-deliver, so a few
|
||||
// thousand ids is ample.
|
||||
const SEEN_IDS_CAP = 5000;
|
||||
const seenIds = new Set<string>();
|
||||
const rememberSeen = (id: string) => {
|
||||
seenIds.add(id);
|
||||
if (seenIds.size > SEEN_IDS_CAP) {
|
||||
const oldest = seenIds.values().next().value;
|
||||
if (oldest !== undefined) seenIds.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const stream = response.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new EventSourceParserStream())
|
||||
.pipeThrough(
|
||||
new TransformStream<EventSourceMessage, SSEStreamPart>({
|
||||
transform: (chunk, chunkController) => {
|
||||
if (streamVersion === "v1") {
|
||||
if (chunk.id) {
|
||||
this.lastEventId = chunk.id;
|
||||
}
|
||||
const timestamp = parseRedisStreamIdTimestamp(chunk.id);
|
||||
chunkController.enqueue({
|
||||
id: chunk.id ?? "unknown",
|
||||
chunk: safeParseJSON(chunk.data),
|
||||
timestamp,
|
||||
});
|
||||
} else {
|
||||
if (chunk.event === "batch") {
|
||||
const data = safeParseJSON(chunk.data) as {
|
||||
records: Array<{
|
||||
body: string;
|
||||
seq_num: number;
|
||||
timestamp: number;
|
||||
headers?: Array<[string, string]>;
|
||||
}>;
|
||||
};
|
||||
if (!data || !Array.isArray(data.records)) return;
|
||||
|
||||
for (const record of data.records) {
|
||||
// Always advance the resume cursor — even for records we
|
||||
// skip — so a future Last-Event-ID reconnect lands past
|
||||
// them.
|
||||
this.lastEventId = record.seq_num.toString();
|
||||
|
||||
// S2 command records (trim, fence) have a single header
|
||||
// with an empty name. They are S2-interpreted directives
|
||||
// that consume a seq_num but are not application data.
|
||||
// Skip enqueue; consumers shouldn't see them.
|
||||
if (record.headers?.[0]?.[0] === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Data record (and Trigger control records — see
|
||||
// `trigger-control` header in `client-protocol.mdx`).
|
||||
// Control records have an empty body; data records have a
|
||||
// JSON envelope. `safeParseJSON("")` returns undefined,
|
||||
// which is what we want for control records — downstream
|
||||
// consumers route by `headers` and ignore `chunk`.
|
||||
const parsedBody = safeParseJSON(record.body) as
|
||||
| { data: unknown; id: string }
|
||||
| undefined;
|
||||
if (parsedBody?.id) {
|
||||
if (seenIds.has(parsedBody.id)) continue;
|
||||
rememberSeen(parsedBody.id);
|
||||
}
|
||||
chunkController.enqueue({
|
||||
id: record.seq_num.toString(),
|
||||
chunk: parsedBody?.data,
|
||||
timestamp: record.timestamp,
|
||||
headers: record.headers ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.signal?.aborted) {
|
||||
reader.cancel();
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
armStall(); // any chunk (including server keepalives) resets the silence timer
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
reader.releaseLock();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.options.signal?.aborted) {
|
||||
// User cancel — exit cleanly, don't retry.
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTriggerRealtimeAuthError(error)) {
|
||||
// `onError` was already invoked in the `!response.ok` branch above
|
||||
// (where the auth ApiError was originally constructed and thrown).
|
||||
// Auth errors are non-retryable: terminate the stream cleanly.
|
||||
controller.error(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupAttempt();
|
||||
await this.retryConnection(controller, error as Error);
|
||||
} finally {
|
||||
cleanupAttempt();
|
||||
}
|
||||
}
|
||||
|
||||
private async retryConnection(
|
||||
controller: ReadableStreamDefaultController,
|
||||
error?: Error
|
||||
): Promise<void> {
|
||||
if (this.options.signal?.aborted) {
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.retryCount >= this.maxRetries) {
|
||||
const finalError = error || new Error("Max retries reached");
|
||||
controller.error(finalError);
|
||||
this.options.onError?.(finalError);
|
||||
return;
|
||||
}
|
||||
|
||||
this.retryCount++;
|
||||
const baseDelay = Math.min(
|
||||
this.retryDelayMs * Math.pow(2, this.retryCount - 1),
|
||||
this.maxRetryDelayMs
|
||||
);
|
||||
// Jitter scales the delay into [(1 - retryJitter) * base, base].
|
||||
// E.g. retryJitter=0.5 → final delay is in [50%, 100%] of base.
|
||||
// Spreads simultaneous reconnect attempts so many clients don't
|
||||
// dogpile on the server right after a brief outage.
|
||||
const delay = baseDelay * (1 - this.retryJitter * Math.random());
|
||||
|
||||
// Wait before retrying. The wait is wakeable: `retryNow()` aborts
|
||||
// `retryNowController` so the timer resolves immediately and the
|
||||
// next connect attempt starts now (e.g. on tab focus / `online`
|
||||
// event from the browser layer).
|
||||
this.retryNowController = new AbortController();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.retryNowController?.signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, delay);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
this.retryNowController!.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
this.retryNowController = null;
|
||||
|
||||
if (this.options.signal?.aborted) {
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
await this.connectStream(controller);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-way abort link: when `parent` aborts, abort `child` too. Returns
|
||||
* a cleanup that removes the listener so `parent` doesn't accumulate
|
||||
* subscriptions across many connect attempts.
|
||||
*/
|
||||
function linkAbort(parent: AbortSignal | undefined, child: AbortController): () => void {
|
||||
if (!parent) return () => {};
|
||||
if (parent.aborted) {
|
||||
child.abort();
|
||||
return () => {};
|
||||
}
|
||||
const onAbort = () => child.abort();
|
||||
parent.addEventListener("abort", onAbort, { once: true });
|
||||
return () => parent.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private options: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: CreateStreamSubscriptionOptions
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/streams/${runId}/${streamKey}`;
|
||||
|
||||
return new SSEStreamSubscription(url, {
|
||||
...this.options,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunShapeProvider {
|
||||
onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
|
||||
export type RunSubscriptionOptions = RunShapeStreamOptions & {
|
||||
runShapeStream: ReadableStream<SubscribeRunRawShape>;
|
||||
stopRunShapeStream: () => void;
|
||||
streamFactory: StreamSubscriptionFactory;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
private stream: AsyncIterableStream<RunShape<TRunTypes>>;
|
||||
private packetCache = new Map<string, any>();
|
||||
private _closeOnComplete: boolean;
|
||||
private _isRunComplete = false;
|
||||
|
||||
constructor(private options: RunSubscriptionOptions) {
|
||||
this._closeOnComplete =
|
||||
typeof options.closeOnComplete === "undefined" ? true : options.closeOnComplete;
|
||||
|
||||
this.stream = createAsyncIterableReadable(
|
||||
this.options.runShapeStream,
|
||||
{
|
||||
transform: async (chunk, controller) => {
|
||||
const run = await this.transformRunShape(chunk);
|
||||
|
||||
controller.enqueue(run);
|
||||
|
||||
// only set the run to complete when finishedAt is set
|
||||
this._isRunComplete = !!run.finishedAt;
|
||||
|
||||
if (
|
||||
this._closeOnComplete &&
|
||||
this._isRunComplete &&
|
||||
!this.options.abortController.signal.aborted
|
||||
) {
|
||||
this.options.stopRunShapeStream();
|
||||
}
|
||||
},
|
||||
},
|
||||
this.options.abortController.signal
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
if (!this.options.abortController.signal.aborted) {
|
||||
this.options.abortController.abort();
|
||||
}
|
||||
this.options.stopRunShapeStream();
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<RunShape<TRunTypes>> {
|
||||
return this.stream[Symbol.asyncIterator]();
|
||||
}
|
||||
|
||||
getReader(): ReadableStreamDefaultReader<RunShape<TRunTypes>> {
|
||||
return this.stream.getReader();
|
||||
}
|
||||
|
||||
withStreams<TStreams extends Record<string, any>>(): AsyncIterableStream<
|
||||
RunWithStreamsResult<RunShape<TRunTypes>, TStreams>
|
||||
> {
|
||||
// Keep track of which streams we've already subscribed to
|
||||
const activeStreams = new Set<string>();
|
||||
|
||||
return createAsyncIterableReadable(
|
||||
this.stream,
|
||||
{
|
||||
transform: async (run, controller) => {
|
||||
controller.enqueue({
|
||||
type: "run",
|
||||
run,
|
||||
});
|
||||
|
||||
const streams = getStreamsFromRunShape(run);
|
||||
|
||||
// Check for stream metadata
|
||||
if (streams.length > 0) {
|
||||
for (const streamKey of streams) {
|
||||
if (typeof streamKey !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activeStreams.has(streamKey)) {
|
||||
activeStreams.add(streamKey);
|
||||
|
||||
const subscription = this.options.streamFactory.createSubscription(
|
||||
run.id,
|
||||
streamKey,
|
||||
{
|
||||
baseUrl: this.options.client?.baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
// Start stream processing in the background
|
||||
subscription.subscribe().then((stream) => {
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk.chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
});
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
this.options.abortController.signal
|
||||
);
|
||||
}
|
||||
|
||||
private async transformRunShape(row: SubscribeRunRawShape): Promise<RunShape<TRunTypes>> {
|
||||
const payloadPacket = row.payloadType
|
||||
? ({ data: row.payload ?? undefined, dataType: row.payloadType } satisfies IOPacket)
|
||||
: undefined;
|
||||
|
||||
const outputPacket = row.outputType
|
||||
? ({ data: row.output ?? undefined, dataType: row.outputType } satisfies IOPacket)
|
||||
: undefined;
|
||||
|
||||
const [payload, output] = await Promise.all(
|
||||
[
|
||||
{ packet: payloadPacket, key: "payload" },
|
||||
{ packet: outputPacket, key: "output" },
|
||||
].map(async ({ packet, key }) => {
|
||||
if (!packet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedResult = this.packetCache.get(`${row.friendlyId}/${key}`);
|
||||
|
||||
if (typeof cachedResult !== "undefined") {
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
const result = await conditionallyImportAndParsePacket(packet, this.options.client);
|
||||
this.packetCache.set(`${row.friendlyId}/${key}`, result);
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const metadata =
|
||||
row.metadata && row.metadataType
|
||||
? await parsePacket({ data: row.metadata, dataType: row.metadataType })
|
||||
: undefined;
|
||||
|
||||
const status = apiStatusFromRunStatus(row.status);
|
||||
|
||||
return {
|
||||
id: row.friendlyId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
taskIdentifier: row.taskIdentifier,
|
||||
status,
|
||||
payload,
|
||||
output,
|
||||
durationMs: row.usageDurationMs ?? 0,
|
||||
costInCents: row.costInCents ?? 0,
|
||||
baseCostInCents: row.baseCostInCents ?? 0,
|
||||
tags: row.runTags ?? [],
|
||||
idempotencyKey: row.idempotencyKey ?? undefined,
|
||||
expiredAt: row.expiredAt ?? undefined,
|
||||
finishedAt: row.completedAt ?? undefined,
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
delayedUntil: row.delayUntil ?? undefined,
|
||||
queuedAt: row.queuedAt ?? undefined,
|
||||
error: row.error ? createJsonErrorObject(row.error) : undefined,
|
||||
isTest: row.isTest ?? false,
|
||||
metadata,
|
||||
realtimeStreams: row.realtimeStreams ?? [],
|
||||
...booleanHelpersFromRunStatus(status),
|
||||
} as RunShape<TRunTypes>;
|
||||
}
|
||||
}
|
||||
|
||||
const queuedStatuses = ["PENDING_VERSION", "QUEUED", "PENDING", "DELAYED"];
|
||||
const waitingStatuses = ["WAITING"];
|
||||
const executingStatuses = ["DEQUEUED", "EXECUTING"];
|
||||
const failedStatuses = ["FAILED", "CRASHED", "SYSTEM_FAILURE", "EXPIRED", "TIMED_OUT"];
|
||||
const successfulStatuses = ["COMPLETED"];
|
||||
|
||||
function booleanHelpersFromRunStatus(status: RunStatus) {
|
||||
return {
|
||||
isQueued: queuedStatuses.includes(status),
|
||||
isWaiting: waitingStatuses.includes(status),
|
||||
isExecuting: executingStatuses.includes(status),
|
||||
isCompleted: successfulStatuses.includes(status) || failedStatuses.includes(status),
|
||||
isFailed: failedStatuses.includes(status),
|
||||
isSuccess: successfulStatuses.includes(status),
|
||||
isCancelled: status === "CANCELED",
|
||||
};
|
||||
}
|
||||
|
||||
function apiStatusFromRunStatus(status: string): RunStatus {
|
||||
switch (status) {
|
||||
case "DELAYED": {
|
||||
return "DELAYED";
|
||||
}
|
||||
case "WAITING_FOR_DEPLOY":
|
||||
case "PENDING_VERSION": {
|
||||
return "PENDING_VERSION";
|
||||
}
|
||||
case "PENDING": {
|
||||
return "QUEUED";
|
||||
}
|
||||
case "PAUSED":
|
||||
case "WAITING_TO_RESUME": {
|
||||
return "WAITING";
|
||||
}
|
||||
case "DEQUEUED": {
|
||||
return "DEQUEUED";
|
||||
}
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
case "EXECUTING": {
|
||||
return "EXECUTING";
|
||||
}
|
||||
case "CANCELED": {
|
||||
return "CANCELED";
|
||||
}
|
||||
case "COMPLETED_SUCCESSFULLY": {
|
||||
return "COMPLETED";
|
||||
}
|
||||
case "SYSTEM_FAILURE": {
|
||||
return "SYSTEM_FAILURE";
|
||||
}
|
||||
case "CRASHED": {
|
||||
return "CRASHED";
|
||||
}
|
||||
case "INTERRUPTED":
|
||||
case "COMPLETED_WITH_ERRORS": {
|
||||
return "FAILED";
|
||||
}
|
||||
case "EXPIRED": {
|
||||
return "EXPIRED";
|
||||
}
|
||||
case "TIMED_OUT": {
|
||||
return "TIMED_OUT";
|
||||
}
|
||||
default: {
|
||||
return "QUEUED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseJSON(data: string): unknown {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (_error) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const isSafari = () => {
|
||||
// Check if we're in a browser environment
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.userAgent === "string"
|
||||
) {
|
||||
return (
|
||||
/^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
);
|
||||
}
|
||||
// If we're not in a browser environment, return false
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* A polyfill for `ReadableStream.protototype[Symbol.asyncIterator]`,
|
||||
* aligning as closely as possible to the specification.
|
||||
*
|
||||
* @see https://streams.spec.whatwg.org/#rs-asynciterator
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#async_iteration
|
||||
*
|
||||
* This is needed for Safari: https://bugs.webkit.org/show_bug.cgi?id=194379
|
||||
*
|
||||
* From https://gist.github.com/MattiasBuelens/496fc1d37adb50a733edd43853f2f60e
|
||||
*
|
||||
*/
|
||||
|
||||
if (isSafari()) {
|
||||
// @ts-ignore-error
|
||||
ReadableStream.prototype.values ??= function ({ preventCancel = false } = {}) {
|
||||
const reader = this.getReader();
|
||||
return {
|
||||
async next(): Promise<IteratorResult<any>> {
|
||||
try {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return {
|
||||
done: result.done,
|
||||
value: result.value,
|
||||
};
|
||||
} catch (e) {
|
||||
reader.releaseLock();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
async return(value: any): Promise<IteratorResult<any>> {
|
||||
if (!preventCancel) {
|
||||
const cancelPromise = reader.cancel(value);
|
||||
reader.releaseLock();
|
||||
await cancelPromise;
|
||||
} else {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { done: true, value };
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-ignore-error
|
||||
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
||||
}
|
||||
|
||||
function getStreamsFromRunShape(run: AnyRunShape): string[] {
|
||||
const metadataStreams =
|
||||
run.metadata &&
|
||||
"$$streams" in run.metadata &&
|
||||
Array.isArray(run.metadata.$$streams) &&
|
||||
run.metadata.$$streams.length > 0 &&
|
||||
run.metadata.$$streams.every((stream) => typeof stream === "string")
|
||||
? run.metadata.$$streams
|
||||
: undefined;
|
||||
|
||||
if (metadataStreams) {
|
||||
return metadataStreams;
|
||||
}
|
||||
|
||||
return run.realtimeStreams;
|
||||
}
|
||||
|
||||
// Redis stream IDs are in the format: <timestamp>-<sequence>
|
||||
function parseRedisStreamIdTimestamp(id?: string): number {
|
||||
if (!id) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
const timestamp = parseInt(id.split("-")[0] as string, 10);
|
||||
if (isNaN(timestamp)) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type Offset,
|
||||
FetchError,
|
||||
isChangeMessage,
|
||||
isControlMessage,
|
||||
ShapeStream,
|
||||
type Message,
|
||||
type Row,
|
||||
type ShapeStreamInterface,
|
||||
} from "@electric-sql/client";
|
||||
import {
|
||||
type AsyncIterableStream,
|
||||
createAsyncIterableStream,
|
||||
} from "../streams/asyncIterableStream.js";
|
||||
import { API_VERSION_HEADER_NAME, API_VERSION } from "./version.js";
|
||||
|
||||
export type ZodShapeStreamOptions = {
|
||||
headers?: Record<string, string>;
|
||||
fetchClient?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
onError?: (e: Error) => void;
|
||||
};
|
||||
|
||||
export type ZodShapeStreamInstance<TShapeSchema extends z.ZodTypeAny> = {
|
||||
stream: AsyncIterableStream<z.output<TShapeSchema>>;
|
||||
stop: (delay?: number) => void;
|
||||
};
|
||||
|
||||
export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
schema: TShapeSchema,
|
||||
url: string,
|
||||
options?: ZodShapeStreamOptions
|
||||
): ZodShapeStreamInstance<TShapeSchema> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
abortController.abort();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const shapeStream = new ShapeStream({
|
||||
url,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
"x-trigger-electric-version": "1.0.0-beta.1",
|
||||
[API_VERSION_HEADER_NAME]: API_VERSION,
|
||||
},
|
||||
fetchClient: options?.fetchClient,
|
||||
signal: abortController.signal,
|
||||
onError: (e) => {
|
||||
options?.onError?.(e);
|
||||
},
|
||||
});
|
||||
|
||||
const readableShape = new ReadableShapeStream(shapeStream);
|
||||
|
||||
const stream = readableShape.stream.pipeThrough(
|
||||
new TransformStream<unknown, z.output<TShapeSchema>>({
|
||||
async transform(chunk, controller) {
|
||||
const result = schema.safeParse(chunk);
|
||||
|
||||
if (result.success) {
|
||||
controller.enqueue(result.data);
|
||||
} else {
|
||||
controller.error(new Error(`Unable to parse shape: ${result.error.message}`));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
stream: stream as AsyncIterableStream<z.output<TShapeSchema>>,
|
||||
stop: (delay?: number) => {
|
||||
if (delay) {
|
||||
setTimeout(() => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
abortController.abort();
|
||||
}, delay);
|
||||
} else {
|
||||
abortController.abort();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class ReadableShapeStream<T extends Row<unknown> = Row> {
|
||||
readonly #stream: ShapeStreamInterface<T>;
|
||||
readonly #currentState: Map<string, T> = new Map();
|
||||
readonly #changeStream: AsyncIterableStream<T>;
|
||||
#error: FetchError | false = false;
|
||||
#unsubscribe?: () => void;
|
||||
#isStreamClosed: boolean = false;
|
||||
|
||||
stop() {
|
||||
this.#isStreamClosed = true;
|
||||
this.#unsubscribe?.();
|
||||
}
|
||||
|
||||
constructor(stream: ShapeStreamInterface<T>) {
|
||||
this.#stream = stream;
|
||||
|
||||
// Create the source stream that will receive messages
|
||||
const source = new ReadableStream<Message<T>[]>({
|
||||
start: (controller) => {
|
||||
this.#unsubscribe = this.#stream.subscribe((messages) => {
|
||||
if (!this.#isStreamClosed) {
|
||||
controller.enqueue(messages);
|
||||
}
|
||||
}, this.#handleError.bind(this));
|
||||
},
|
||||
cancel: () => {
|
||||
this.#isStreamClosed = true;
|
||||
this.#unsubscribe?.();
|
||||
},
|
||||
});
|
||||
|
||||
let updatedKeys = new Set<string>();
|
||||
|
||||
// Create the transformed stream that processes messages and emits complete rows
|
||||
this.#changeStream = createAsyncIterableStream(source, {
|
||||
transform: (messages, controller) => {
|
||||
if (this.#isStreamClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let isUpToDate = false;
|
||||
|
||||
for (const message of messages) {
|
||||
if (isChangeMessage(message)) {
|
||||
const key = message.key;
|
||||
switch (message.headers.operation) {
|
||||
case "insert": {
|
||||
this.#currentState.set(key, message.value);
|
||||
updatedKeys.add(key);
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
const existingRow = this.#currentState.get(key);
|
||||
const updatedRow = existingRow
|
||||
? { ...existingRow, ...message.value }
|
||||
: message.value;
|
||||
this.#currentState.set(key, updatedRow);
|
||||
updatedKeys.add(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (isControlMessage(message)) {
|
||||
if (message.headers.control === "must-refetch") {
|
||||
this.#currentState.clear();
|
||||
this.#error = false;
|
||||
} else if (message.headers.control === "up-to-date") {
|
||||
isUpToDate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now enqueue only one updated row per key, after all messages have been processed.
|
||||
// If the stream is not up to date, we don't want to enqueue any rows.
|
||||
if (!this.#isStreamClosed && isUpToDate) {
|
||||
for (const key of updatedKeys) {
|
||||
const finalRow = this.#currentState.get(key);
|
||||
if (finalRow) {
|
||||
controller.enqueue(finalRow);
|
||||
}
|
||||
}
|
||||
|
||||
updatedKeys.clear();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing stream messages:", error);
|
||||
this.#handleError(error as Error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get stream(): AsyncIterableStream<T> {
|
||||
return this.#changeStream;
|
||||
}
|
||||
|
||||
get isUpToDate(): boolean {
|
||||
return this.#stream.isUpToDate;
|
||||
}
|
||||
|
||||
get lastOffset(): Offset {
|
||||
return this.#stream.lastOffset;
|
||||
}
|
||||
|
||||
get handle(): string | undefined {
|
||||
return this.#stream.shapeHandle;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.#error;
|
||||
}
|
||||
|
||||
lastSyncedAt(): number | undefined {
|
||||
return this.#stream.lastSyncedAt();
|
||||
}
|
||||
|
||||
lastSynced() {
|
||||
return this.#stream.lastSynced();
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.#stream.isLoading();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.#stream.isConnected();
|
||||
}
|
||||
|
||||
#handleError(e: Error): void {
|
||||
if (e instanceof FetchError) {
|
||||
this.#error = e;
|
||||
}
|
||||
this.#isStreamClosed = true;
|
||||
this.#unsubscribe?.();
|
||||
}
|
||||
}
|
||||
|
||||
export class LineTransformStream extends TransformStream<string, string[]> {
|
||||
private buffer = "";
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
transform: (chunk, controller) => {
|
||||
// Append the chunk to the buffer
|
||||
this.buffer += chunk;
|
||||
|
||||
// Split on newlines
|
||||
const lines = this.buffer.split("\n");
|
||||
|
||||
// The last element might be incomplete, hold it back in buffer
|
||||
this.buffer = lines.pop() || "";
|
||||
|
||||
// Filter out empty or whitespace-only lines
|
||||
const fullLines = lines.filter((line) => line.trim().length > 0);
|
||||
|
||||
// If we got any complete lines, emit them as an array
|
||||
if (fullLines.length > 0) {
|
||||
controller.enqueue(fullLines);
|
||||
}
|
||||
},
|
||||
flush: (controller) => {
|
||||
// On stream end, if there's leftover text, emit it as a single-element array
|
||||
const trimmed = this.buffer.trim();
|
||||
if (trimmed.length > 0) {
|
||||
controller.enqueue([trimmed]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ApiClient } from "./index.js";
|
||||
import { BatchNotSealedError } from "./errors.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
describe("streamBatchItems unsealed handling", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a mock fetch that properly consumes the request body stream.
|
||||
* This is necessary because streamBatchItems sends a ReadableStream body.
|
||||
* Important: We must release the reader lock after consuming, just like real fetch does.
|
||||
*/
|
||||
function createMockFetch(
|
||||
responses: Array<{
|
||||
id: string;
|
||||
itemsAccepted: number;
|
||||
itemsDeduplicated: number;
|
||||
sealed: boolean;
|
||||
enqueuedCount?: number;
|
||||
expectedCount?: number;
|
||||
}>
|
||||
) {
|
||||
let callIndex = 0;
|
||||
return vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
// Consume the request body stream to prevent hanging
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
const reader = init.body.getReader();
|
||||
// Drain the stream
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
// Release the lock so the stream can be cancelled later (like real fetch does)
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
const responseData = responses[Math.min(callIndex, responses.length - 1)];
|
||||
callIndex++;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(responseData),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("throws BatchNotSealedError when sealed=false after retries exhausted", async () => {
|
||||
const mockFetch = createMockFetch([
|
||||
{
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: false,
|
||||
enqueuedCount: 5,
|
||||
expectedCount: 10,
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const error = await client
|
||||
.streamBatchItems("batch_test123", [{ index: 0, task: "test-task", payload: "{}" }], {
|
||||
retry: { maxAttempts: 2, minTimeoutInMs: 10, maxTimeoutInMs: 50 },
|
||||
})
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(BatchNotSealedError);
|
||||
expect((error as BatchNotSealedError).batchId).toBe("batch_test123");
|
||||
expect((error as BatchNotSealedError).enqueuedCount).toBe(5);
|
||||
expect((error as BatchNotSealedError).expectedCount).toBe(10);
|
||||
expect((error as BatchNotSealedError).itemsAccepted).toBe(5);
|
||||
expect((error as BatchNotSealedError).itemsDeduplicated).toBe(0);
|
||||
|
||||
// Should have retried (2 attempts total based on maxAttempts)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries when sealed=false and succeeds when sealed=true on retry", async () => {
|
||||
const mockFetch = createMockFetch([
|
||||
// First response: not sealed
|
||||
{
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: false,
|
||||
enqueuedCount: 5,
|
||||
expectedCount: 10,
|
||||
},
|
||||
// Second response: sealed
|
||||
{
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
// Should have been called twice (first unsealed, second sealed)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("succeeds immediately when sealed=true on first attempt", async () => {
|
||||
const mockFetch = createMockFetch([
|
||||
{
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 10,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems("batch_test123", [
|
||||
{ index: 0, task: "test-task", payload: "{}" },
|
||||
]);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(result.itemsAccepted).toBe(10);
|
||||
// Should only be called once
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("BatchNotSealedError has descriptive message", async () => {
|
||||
const mockFetch = createMockFetch([
|
||||
{
|
||||
id: "batch_abc123",
|
||||
itemsAccepted: 7,
|
||||
itemsDeduplicated: 2,
|
||||
sealed: false,
|
||||
enqueuedCount: 9,
|
||||
expectedCount: 15,
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const error = await client
|
||||
.streamBatchItems("batch_abc123", [{ index: 0, task: "test-task", payload: "{}" }], {
|
||||
retry: { maxAttempts: 1, minTimeoutInMs: 10, maxTimeoutInMs: 50 },
|
||||
})
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(BatchNotSealedError);
|
||||
expect(error.message).toContain("batch_abc123");
|
||||
expect(error.message).toContain("9 of 15");
|
||||
expect(error.message).toContain("accepted: 7");
|
||||
expect(error.message).toContain("deduplicated: 2");
|
||||
});
|
||||
|
||||
it("handles missing enqueuedCount and expectedCount gracefully", async () => {
|
||||
// Simulate older server response that might not include these fields
|
||||
const mockFetch = createMockFetch([
|
||||
{
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: false,
|
||||
// No enqueuedCount or expectedCount
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const error = await client
|
||||
.streamBatchItems("batch_test123", [{ index: 0, task: "test-task", payload: "{}" }], {
|
||||
retry: { maxAttempts: 1, minTimeoutInMs: 10, maxTimeoutInMs: 50 },
|
||||
})
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(BatchNotSealedError);
|
||||
// Should default to 0 when not provided
|
||||
expect((error as BatchNotSealedError).enqueuedCount).toBe(0);
|
||||
expect((error as BatchNotSealedError).expectedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamBatchItems stream cancellation on retry", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to consume a stream and release the lock (simulating fetch behavior).
|
||||
*/
|
||||
async function consumeAndRelease(stream: ReadableStream<any>) {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
it("cancels forRequest stream when retrying due to HTTP error", async () => {
|
||||
// Track cancel calls
|
||||
let cancelCallCount = 0;
|
||||
let callIndex = 0;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const currentAttempt = callIndex;
|
||||
callIndex++;
|
||||
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
const originalCancel = init.body.cancel.bind(init.body);
|
||||
init.body.cancel = async (reason?: any) => {
|
||||
cancelCallCount++;
|
||||
return originalCancel(reason);
|
||||
};
|
||||
|
||||
// Consume stream and release lock (like real fetch does)
|
||||
await consumeAndRelease(init.body);
|
||||
}
|
||||
|
||||
// First attempt: return 500 error (retryable)
|
||||
if (currentAttempt === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Server error"),
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
|
||||
// Second attempt: success
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 10,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// forRequest should be cancelled once (before first retry)
|
||||
// forRetry should be cancelled once (after success)
|
||||
// Total: 2 cancel calls
|
||||
expect(cancelCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("cancels forRequest stream when retrying due to batch not sealed", async () => {
|
||||
let cancelCallCount = 0;
|
||||
let callIndex = 0;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const currentAttempt = callIndex;
|
||||
callIndex++;
|
||||
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
const originalCancel = init.body.cancel.bind(init.body);
|
||||
init.body.cancel = async (reason?: any) => {
|
||||
cancelCallCount++;
|
||||
return originalCancel(reason);
|
||||
};
|
||||
|
||||
await consumeAndRelease(init.body);
|
||||
}
|
||||
|
||||
// First attempt: not sealed (triggers retry)
|
||||
if (currentAttempt === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: false,
|
||||
enqueuedCount: 5,
|
||||
expectedCount: 10,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Second attempt: sealed
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 5,
|
||||
sealed: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// forRequest cancelled before retry + forRetry cancelled after success
|
||||
expect(cancelCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("cancels forRequest stream when retrying due to connection error", async () => {
|
||||
let cancelCallCount = 0;
|
||||
let callIndex = 0;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const currentAttempt = callIndex;
|
||||
callIndex++;
|
||||
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
const originalCancel = init.body.cancel.bind(init.body);
|
||||
init.body.cancel = async (reason?: any) => {
|
||||
cancelCallCount++;
|
||||
return originalCancel(reason);
|
||||
};
|
||||
|
||||
// Always consume and release - even for error case
|
||||
// This simulates what happens when fetch partially reads before failing
|
||||
// The important thing is the stream lock is released so cancel() can work
|
||||
await consumeAndRelease(init.body);
|
||||
}
|
||||
|
||||
// First attempt: connection error (simulate by throwing after consuming)
|
||||
if (currentAttempt === 0) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
|
||||
// Second attempt: success
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 10,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
// forRequest should be cancelled before retry
|
||||
expect(cancelCallCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles locked stream when connection error occurs mid-read", async () => {
|
||||
// This test simulates the real-world scenario where fetch throws an error
|
||||
// while still holding the reader lock on the request body stream.
|
||||
// This can happen with connection resets, timeouts, or network failures.
|
||||
let callIndex = 0;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const currentAttempt = callIndex;
|
||||
callIndex++;
|
||||
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
if (currentAttempt === 0) {
|
||||
// First attempt: Get a reader and start reading, but throw while still holding the lock.
|
||||
// This simulates a connection error that happens mid-transfer.
|
||||
const reader = init.body.getReader();
|
||||
await reader.read(); // Start reading
|
||||
// DON'T release the lock - this simulates fetch crashing mid-read
|
||||
throw new TypeError("Connection reset by peer");
|
||||
}
|
||||
|
||||
// Subsequent attempts: consume and release normally
|
||||
await consumeAndRelease(init.body);
|
||||
}
|
||||
|
||||
// Second attempt: success
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 10,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
// This should NOT throw "ReadableStream is locked" error
|
||||
// Instead it should gracefully handle the locked stream and retry
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 3, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not leak memory by leaving tee branches unconsumed during multiple retries", async () => {
|
||||
let cancelCallCount = 0;
|
||||
let callIndex = 0;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
||||
const currentAttempt = callIndex;
|
||||
callIndex++;
|
||||
|
||||
if (init?.body && init.body instanceof ReadableStream) {
|
||||
const originalCancel = init.body.cancel.bind(init.body);
|
||||
init.body.cancel = async (reason?: any) => {
|
||||
cancelCallCount++;
|
||||
return originalCancel(reason);
|
||||
};
|
||||
|
||||
await consumeAndRelease(init.body);
|
||||
}
|
||||
|
||||
// First two attempts: not sealed
|
||||
if (currentAttempt < 2) {
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: false,
|
||||
enqueuedCount: 5,
|
||||
expectedCount: 10,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Third attempt: sealed
|
||||
return {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: "batch_test123",
|
||||
itemsAccepted: 5,
|
||||
itemsDeduplicated: 5,
|
||||
sealed: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
const client = new ApiClient("http://localhost:3030", "tr_test_key");
|
||||
|
||||
const result = await client.streamBatchItems(
|
||||
"batch_test123",
|
||||
[{ index: 0, task: "test-task", payload: "{}" }],
|
||||
{ retry: { maxAttempts: 5, minTimeoutInMs: 10, maxTimeoutInMs: 50 } }
|
||||
);
|
||||
|
||||
expect(result.sealed).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
// Each retry should cancel forRequest, plus final forRetry cancel
|
||||
// With 2 retries: 2 forRequest cancels + 1 forRetry cancel = 3 total
|
||||
expect(cancelCallCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
MachinePresetName,
|
||||
QueueTypeName,
|
||||
RunStatus,
|
||||
WaitpointTokenStatus,
|
||||
} from "../schemas/index.js";
|
||||
import type { CursorPageParams } from "./pagination.js";
|
||||
|
||||
export interface ImportEnvironmentVariablesParams {
|
||||
/**
|
||||
* The variables to be imported. If a variable with the same key already exists, it will be overwritten when `override` is `true`.
|
||||
*
|
||||
* To specify the variables, you can pass them in as a record of key-value pairs. e.g. `{ "key1": "value1", "key2": "value2" }`
|
||||
*/
|
||||
variables: Record<string, string>;
|
||||
override?: boolean;
|
||||
/**
|
||||
* When `true`, the imported variables are created as secret (redacted) environment variables. Defaults to `false`.
|
||||
*/
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateEnvironmentVariableParams {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface UpdateEnvironmentVariableParams {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ListRunsQueryParams extends CursorPageParams {
|
||||
status?: Array<RunStatus> | RunStatus;
|
||||
taskIdentifier?: Array<string> | string;
|
||||
version?: Array<string> | string;
|
||||
from?: Date | number;
|
||||
to?: Date | number;
|
||||
period?: string;
|
||||
bulkAction?: string;
|
||||
tag?: Array<string> | string;
|
||||
schedule?: string;
|
||||
isTest?: boolean;
|
||||
batch?: string;
|
||||
/**
|
||||
* The queue type and name, or multiple of them.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const runs = await runs.list({
|
||||
* queue: { type: "task", name: "my-task-id" },
|
||||
* });
|
||||
*
|
||||
* // Or multiple queues
|
||||
* const runs = await runs.list({
|
||||
* queue: [
|
||||
* { type: "custom", name: "my-custom-queue" },
|
||||
* { type: "task", name: "my-task-id" },
|
||||
* ],
|
||||
* });
|
||||
* ```
|
||||
* */
|
||||
queue?: Array<QueueTypeName> | QueueTypeName;
|
||||
/** The machine name, or multiple of them. */
|
||||
machine?: Array<MachinePresetName> | MachinePresetName;
|
||||
/** The region master-queue identifier, or multiple of them. */
|
||||
region?: Array<string> | string;
|
||||
}
|
||||
|
||||
export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams {
|
||||
env?: Array<"dev" | "staging" | "prod"> | "dev" | "staging" | "prod";
|
||||
}
|
||||
|
||||
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = T &
|
||||
{
|
||||
[K in Keys]-?: Required<Pick<T, K>>;
|
||||
}[Keys];
|
||||
|
||||
/** Same filters as runs.list(), excluding pagination. */
|
||||
export type BulkActionFilter = RequireAtLeastOne<Omit<ListRunsQueryParams, keyof CursorPageParams>>;
|
||||
|
||||
export type BulkActionSelection =
|
||||
| { filter: BulkActionFilter; runIds?: never }
|
||||
| { runIds: string[]; filter?: never };
|
||||
|
||||
type BaseBulkActionOptions = BulkActionSelection & {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type TargetRegionOption = {
|
||||
/** Region identifier to replay runs in. When omitted, each replay keeps the original run's region. */
|
||||
targetRegion?: string;
|
||||
};
|
||||
|
||||
export type CreateBulkActionOptions =
|
||||
| (BaseBulkActionOptions & {
|
||||
action: "cancel";
|
||||
targetRegion?: never;
|
||||
})
|
||||
| (BaseBulkActionOptions & { action: "replay" } & TargetRegionOption);
|
||||
|
||||
export type CreateBulkCancelActionOptions = BaseBulkActionOptions & {
|
||||
targetRegion?: never;
|
||||
};
|
||||
|
||||
export type CreateBulkReplayActionOptions = BaseBulkActionOptions & TargetRegionOption;
|
||||
|
||||
export type ListBulkActionsQueryParams = CursorPageParams;
|
||||
|
||||
export interface SubscribeToRunsQueryParams {
|
||||
tasks?: Array<string> | string;
|
||||
tags?: Array<string> | string;
|
||||
createdAt?: string;
|
||||
skipColumns?: string[];
|
||||
}
|
||||
|
||||
export interface ListWaitpointTokensQueryParams extends CursorPageParams {
|
||||
status?: Array<WaitpointTokenStatus> | WaitpointTokenStatus;
|
||||
idempotencyKey?: string;
|
||||
tags?: Array<string> | string;
|
||||
period?: string;
|
||||
from?: Date | number;
|
||||
to?: Date | number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const API_VERSION = "2025-07-16";
|
||||
export const API_VERSION_HEADER_NAME = "x-trigger-api-version";
|
||||
@@ -0,0 +1,7 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { APIClientManagerAPI } from "./apiClientManager/index.js";
|
||||
/** Entrypoint for logger API */
|
||||
export const apiClientManager = APIClientManagerAPI.getInstance();
|
||||
|
||||
export type { ApiClientConfiguration } from "./apiClientManager/types.js";
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ApiClient } from "../apiClient/index.js";
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
import { isDefaultDevBranch } from "../utils/gitBranch.js";
|
||||
import { sdkScope } from "../sdkScope/index.js";
|
||||
import type { ApiClientConfiguration } from "./types.js";
|
||||
|
||||
const API_NAME = "api-client";
|
||||
|
||||
/**
|
||||
* Read the dev-side branch carrier env var, collapsing the `"default"` sentinel
|
||||
* to `undefined` so it never leaks into the `x-trigger-branch` header (the
|
||||
* sentinel refers to the root dev env, which carries no branch).
|
||||
*/
|
||||
function getDevBranchEnvVar(): string | undefined {
|
||||
const value = getEnvVar("TRIGGER_DEV_BRANCH");
|
||||
return value && !isDefaultDevBranch(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export class ApiClientMissingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ApiClientMissingError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIClientManagerAPI {
|
||||
private static _instance?: APIClientManagerAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): APIClientManagerAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new APIClientManagerAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public disable() {
|
||||
unregisterGlobal(API_NAME);
|
||||
}
|
||||
|
||||
get baseURL(): string | undefined {
|
||||
const scoped = sdkScope.getStore();
|
||||
if (scoped) {
|
||||
return scoped.apiClientConfig.baseURL ?? "https://api.trigger.dev";
|
||||
}
|
||||
const config = this.#getConfig();
|
||||
return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
|
||||
}
|
||||
|
||||
get accessToken(): string | undefined {
|
||||
const scoped = sdkScope.getStore();
|
||||
if (scoped) {
|
||||
return scoped.apiClientConfig.accessToken ?? scoped.apiClientConfig.secretKey;
|
||||
}
|
||||
const config = this.#getConfig();
|
||||
return (
|
||||
config?.secretKey ??
|
||||
config?.accessToken ??
|
||||
getEnvVar("TRIGGER_SECRET_KEY") ??
|
||||
getEnvVar("TRIGGER_ACCESS_TOKEN")
|
||||
);
|
||||
}
|
||||
|
||||
get branchName(): string | undefined {
|
||||
const scoped = sdkScope.getStore();
|
||||
if (scoped) {
|
||||
// previewBranch carries the branch for any branchable env (preview or dev) —
|
||||
// they share the x-trigger-branch header. resolveApiClientConfig folds in the
|
||||
// dev-side TRIGGER_DEV_BRANCH carrier when building the scoped config.
|
||||
const value = scoped.apiClientConfig.previewBranch;
|
||||
return value ? value : undefined;
|
||||
}
|
||||
const config = this.#getConfig();
|
||||
const value =
|
||||
config?.previewBranch ??
|
||||
getEnvVar("TRIGGER_PREVIEW_BRANCH") ??
|
||||
getEnvVar("VERCEL_GIT_COMMIT_REF") ??
|
||||
// Dev branches share the x-trigger-branch header; TRIGGER_DEV_BRANCH is the
|
||||
// dev-side carrier. Never read the "default" sentinel.
|
||||
getDevBranchEnvVar() ??
|
||||
undefined;
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
public resolveApiClientConfig(partial: ApiClientConfiguration = {}): ApiClientConfiguration {
|
||||
return {
|
||||
baseURL: partial.baseURL ?? getEnvVar("TRIGGER_API_URL"),
|
||||
accessToken:
|
||||
partial.accessToken ??
|
||||
partial.secretKey ??
|
||||
getEnvVar("TRIGGER_SECRET_KEY") ??
|
||||
getEnvVar("TRIGGER_ACCESS_TOKEN"),
|
||||
secretKey: partial.secretKey,
|
||||
previewBranch:
|
||||
partial.previewBranch ??
|
||||
getEnvVar("TRIGGER_PREVIEW_BRANCH") ??
|
||||
getEnvVar("VERCEL_GIT_COMMIT_REF") ??
|
||||
getDevBranchEnvVar(),
|
||||
requestOptions: partial.requestOptions,
|
||||
future: partial.future,
|
||||
};
|
||||
}
|
||||
|
||||
get client(): ApiClient | undefined {
|
||||
if (!this.baseURL || !this.accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scoped = sdkScope.getStore();
|
||||
const source = scoped?.apiClientConfig ?? this.#getConfig();
|
||||
const requestOptions = source?.requestOptions;
|
||||
const futureFlags = source?.future;
|
||||
|
||||
return new ApiClient(
|
||||
this.baseURL,
|
||||
this.accessToken,
|
||||
this.branchName,
|
||||
requestOptions,
|
||||
futureFlags
|
||||
);
|
||||
}
|
||||
|
||||
clientOrThrow(config?: ApiClientConfiguration): ApiClient {
|
||||
const baseURL = config?.baseURL ?? this.baseURL;
|
||||
const accessToken = config?.accessToken ?? config?.secretKey ?? this.accessToken;
|
||||
|
||||
if (!baseURL || !accessToken) {
|
||||
throw new ApiClientMissingError(this.apiClientMissingError());
|
||||
}
|
||||
|
||||
const branchName = config?.previewBranch ?? this.branchName;
|
||||
const scoped = sdkScope.getStore();
|
||||
const source = scoped?.apiClientConfig ?? this.#getConfig();
|
||||
const requestOptions = config?.requestOptions ?? source?.requestOptions;
|
||||
const futureFlags = config?.future ?? source?.future;
|
||||
|
||||
return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags);
|
||||
}
|
||||
|
||||
runWithConfig<R extends (...args: any[]) => Promise<any>>(
|
||||
config: ApiClientConfiguration,
|
||||
fn: R
|
||||
): Promise<ReturnType<R>> {
|
||||
const current = sdkScope.getStore()?.apiClientConfig ?? this.#getConfig();
|
||||
const merged = this.resolveApiClientConfig({ ...current, ...config });
|
||||
|
||||
if (sdkScope.hasStorage()) {
|
||||
return sdkScope.withScope({ apiClientConfig: merged, inheritContext: true }, fn);
|
||||
}
|
||||
|
||||
// No ALS available (browser, edge, workers). Fall back to in-place
|
||||
// mutation — same as pre-existing behavior, not concurrency-safe.
|
||||
const original = this.#getConfig();
|
||||
registerGlobal(API_NAME, merged, true);
|
||||
return fn().finally(() => {
|
||||
registerGlobal(API_NAME, original, true);
|
||||
});
|
||||
}
|
||||
|
||||
public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean {
|
||||
return registerGlobal(API_NAME, config, true);
|
||||
}
|
||||
|
||||
#getConfig(): ApiClientConfiguration | undefined {
|
||||
return getGlobal(API_NAME);
|
||||
}
|
||||
|
||||
apiClientMissingError() {
|
||||
const hasBaseUrl = !!this.baseURL;
|
||||
const hasAccessToken = !!this.accessToken;
|
||||
if (!hasBaseUrl && !hasAccessToken) {
|
||||
return `You need to set the TRIGGER_API_URL and TRIGGER_SECRET_KEY environment variables. See https://trigger.dev/docs/management/overview#authentication`;
|
||||
} else if (!hasBaseUrl) {
|
||||
return `You need to set the TRIGGER_API_URL environment variable. See https://trigger.dev/docs/management/overview#authentication`;
|
||||
} else if (!hasAccessToken) {
|
||||
return `You need to set the TRIGGER_SECRET_KEY environment variable. See https://trigger.dev/docs/management/overview#authentication`;
|
||||
}
|
||||
|
||||
return `Unknown error`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ApiClientFutureFlags, ApiRequestOptions } from "../apiClient/index.js";
|
||||
|
||||
export type ApiClientConfiguration = {
|
||||
baseURL?: string;
|
||||
/**
|
||||
* @deprecated Use `accessToken` instead.
|
||||
*/
|
||||
secretKey?: string;
|
||||
/**
|
||||
* The access token to authenticate with the Trigger API.
|
||||
*/
|
||||
accessToken?: string;
|
||||
/**
|
||||
* The preview branch name (for preview environments)
|
||||
*/
|
||||
previewBranch?: string;
|
||||
requestOptions?: ApiRequestOptions;
|
||||
future?: ApiClientFutureFlags;
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { setTimeout as timeout } from "node:timers/promises";
|
||||
|
||||
type ExponentialBackoffType = "NoJitter" | "FullJitter" | "EqualJitter";
|
||||
|
||||
type ExponentialBackoffOptions = {
|
||||
base: number;
|
||||
factor: number;
|
||||
min: number;
|
||||
max: number;
|
||||
maxRetries: number;
|
||||
maxElapsed: number;
|
||||
};
|
||||
|
||||
class StopRetrying extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = "StopRetrying";
|
||||
}
|
||||
}
|
||||
|
||||
class AttemptTimeout extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = "AttemptTimeout";
|
||||
}
|
||||
}
|
||||
|
||||
class RetryLimitExceeded extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = "RetryLimitExceeded";
|
||||
}
|
||||
}
|
||||
|
||||
type YieldType<T> = T extends AsyncGenerator<infer Y, any, any> ? Y : never;
|
||||
|
||||
/**
|
||||
* Exponential backoff helper class
|
||||
* - All time units in seconds unless otherwise specified
|
||||
*/
|
||||
export class ExponentialBackoff {
|
||||
#retries: number = 0;
|
||||
|
||||
#type: ExponentialBackoffType;
|
||||
#base: number;
|
||||
#factor: number;
|
||||
|
||||
#min: number;
|
||||
#max: number;
|
||||
|
||||
#maxRetries: number;
|
||||
#maxElapsed: number;
|
||||
|
||||
constructor(type?: ExponentialBackoffType, opts: Partial<ExponentialBackoffOptions> = {}) {
|
||||
this.#type = type ?? "NoJitter";
|
||||
this.#base = opts.base ?? 2;
|
||||
this.#factor = opts.factor ?? 1;
|
||||
|
||||
this.#min = opts.min ?? -Infinity;
|
||||
this.#max = opts.max ?? Infinity;
|
||||
|
||||
this.#maxRetries = opts.maxRetries ?? Infinity;
|
||||
this.#maxElapsed = opts.maxElapsed ?? Infinity;
|
||||
}
|
||||
|
||||
#clone(type?: ExponentialBackoffType, opts: Partial<ExponentialBackoffOptions> = {}) {
|
||||
return new ExponentialBackoff(type ?? this.#type, {
|
||||
base: opts.base ?? this.#base,
|
||||
factor: opts.factor ?? this.#factor,
|
||||
min: opts.min ?? this.#min,
|
||||
max: opts.max ?? this.#max,
|
||||
maxRetries: opts.maxRetries ?? this.#maxRetries,
|
||||
maxElapsed: opts.maxElapsed ?? this.#maxElapsed,
|
||||
});
|
||||
}
|
||||
|
||||
type(type?: ExponentialBackoffType) {
|
||||
return this.#clone(type);
|
||||
}
|
||||
|
||||
base(base?: number) {
|
||||
return this.#clone(undefined, { base });
|
||||
}
|
||||
|
||||
factor(factor?: number) {
|
||||
return this.#clone(undefined, { factor });
|
||||
}
|
||||
|
||||
min(min?: number) {
|
||||
return this.#clone(undefined, { min });
|
||||
}
|
||||
|
||||
max(max?: number) {
|
||||
return this.#clone(undefined, { max });
|
||||
}
|
||||
|
||||
maxRetries(maxRetries?: number) {
|
||||
return this.#clone(undefined, { maxRetries });
|
||||
}
|
||||
|
||||
// TODO: With .execute(), should this also include the time it takes to execute the callback?
|
||||
maxElapsed(maxElapsed?: number) {
|
||||
return this.#clone(undefined, { maxElapsed });
|
||||
}
|
||||
|
||||
retries(retries?: number) {
|
||||
if (typeof retries !== "undefined") {
|
||||
if (retries > this.#maxRetries) {
|
||||
console.error(
|
||||
`Can't set retries ${retries} higher than maxRetries (${
|
||||
this.#maxRetries
|
||||
}), setting to maxRetries instead.`
|
||||
);
|
||||
this.#retries = this.#maxRetries;
|
||||
} else {
|
||||
this.#retries = retries;
|
||||
}
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
async *retryAsync(maxRetries: number = this.#maxRetries ?? Infinity) {
|
||||
let elapsed = 0;
|
||||
let retry = 0;
|
||||
|
||||
while (retry <= maxRetries) {
|
||||
const delay = this.delay(retry);
|
||||
elapsed += delay;
|
||||
|
||||
if (elapsed > this.#maxElapsed) {
|
||||
break;
|
||||
}
|
||||
|
||||
yield {
|
||||
delay: {
|
||||
seconds: delay,
|
||||
milliseconds: delay * 1000,
|
||||
},
|
||||
retry,
|
||||
};
|
||||
|
||||
retry++;
|
||||
}
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* this.retryAsync();
|
||||
}
|
||||
|
||||
/** Returns the delay for the current retry in seconds. */
|
||||
delay(retries: number = this.#retries, jitter: boolean = true) {
|
||||
if (retries > this.#maxRetries) {
|
||||
console.error(
|
||||
`Can't set retries ${retries} higher than maxRetries (${
|
||||
this.#maxRetries
|
||||
}), setting to maxRetries instead.`
|
||||
);
|
||||
retries = this.#maxRetries;
|
||||
}
|
||||
|
||||
let delay = this.#factor * this.#base ** retries;
|
||||
|
||||
switch (this.#type) {
|
||||
case "NoJitter": {
|
||||
break;
|
||||
}
|
||||
case "FullJitter": {
|
||||
if (!jitter) {
|
||||
delay = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
delay *= Math.random();
|
||||
break;
|
||||
}
|
||||
case "EqualJitter": {
|
||||
if (!jitter) {
|
||||
delay *= 0.5;
|
||||
break;
|
||||
}
|
||||
|
||||
delay *= 0.5 * (1 + Math.random());
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown backoff type: ${this.#type}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If min/max override the delay, jitter with 20% while respecting min/max
|
||||
if (delay < this.#min) {
|
||||
delay = this.#min + Math.random() * (this.#min * 0.2);
|
||||
}
|
||||
if (delay > this.#max) {
|
||||
delay = this.#max - Math.random() * (this.#max * 0.2);
|
||||
}
|
||||
|
||||
delay = Math.round(delay);
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
/** Waits with the appropriate delay for the current retry. */
|
||||
async wait(retries: number = this.#retries, jitter: boolean = true) {
|
||||
if (retries > this.#maxRetries) {
|
||||
console.error(`Retry limit exceeded: ${retries} > ${this.#maxRetries}`);
|
||||
throw new RetryLimitExceeded();
|
||||
}
|
||||
|
||||
const delay = this.delay(retries, jitter);
|
||||
|
||||
return await timeout(delay * 1000);
|
||||
}
|
||||
|
||||
elapsed(retries: number = this.#retries, jitter: boolean = true) {
|
||||
let elapsed = 0;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
elapsed += this.delay(i, jitter);
|
||||
}
|
||||
|
||||
const total = elapsed;
|
||||
|
||||
let days = 0;
|
||||
if (elapsed > 3600 * 24) {
|
||||
days = Math.floor(elapsed / 3600 / 24);
|
||||
elapsed -= days * 3600 * 24;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
if (elapsed > 3600) {
|
||||
hours = Math.floor(elapsed / 3600);
|
||||
elapsed -= hours * 3600;
|
||||
}
|
||||
|
||||
let minutes = 0;
|
||||
if (elapsed > 60) {
|
||||
minutes = Math.floor(elapsed / 60);
|
||||
elapsed -= minutes * 60;
|
||||
}
|
||||
|
||||
const seconds = elapsed;
|
||||
|
||||
return {
|
||||
seconds,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#retries = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
next() {
|
||||
this.#retries++;
|
||||
return this.delay();
|
||||
}
|
||||
|
||||
stop() {
|
||||
throw new StopRetrying();
|
||||
}
|
||||
|
||||
get state() {
|
||||
return {
|
||||
retries: this.#retries,
|
||||
type: this.#type,
|
||||
base: this.#base,
|
||||
factor: this.#factor,
|
||||
min: this.#min,
|
||||
max: this.#max,
|
||||
maxRetries: this.#maxRetries,
|
||||
maxElapsed: this.#maxElapsed,
|
||||
};
|
||||
}
|
||||
|
||||
async execute<T>(
|
||||
callback: (
|
||||
iteratorReturn: YieldType<ReturnType<ExponentialBackoff["retryAsync"]>> & {
|
||||
elapsedMs: number;
|
||||
}
|
||||
) => Promise<T>,
|
||||
{ attemptTimeoutMs = 0 }: { attemptTimeoutMs?: number } = {}
|
||||
): Promise<
|
||||
| { success: true; result: T }
|
||||
| { success: false; error?: unknown; cause: "StopRetrying" | "Timeout" | "MaxRetries" }
|
||||
> {
|
||||
let elapsedMs = 0;
|
||||
let finalError: unknown = undefined;
|
||||
|
||||
for await (const { delay, retry } of this) {
|
||||
const start = Date.now();
|
||||
|
||||
if (retry > 0) {
|
||||
console.log(`Retrying in ${delay.milliseconds}ms`);
|
||||
await timeout(delay.milliseconds);
|
||||
}
|
||||
|
||||
let attemptTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
try {
|
||||
const result = await new Promise<T>((resolve, reject) => {
|
||||
if (attemptTimeoutMs > 0) {
|
||||
attemptTimeout = setTimeout(() => {
|
||||
reject(new AttemptTimeout());
|
||||
}, attemptTimeoutMs);
|
||||
}
|
||||
|
||||
callback({ delay, retry, elapsedMs }).then(resolve, reject);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
finalError = error;
|
||||
|
||||
if (error instanceof StopRetrying) {
|
||||
return {
|
||||
success: false,
|
||||
cause: "StopRetrying",
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof AttemptTimeout) {
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
elapsedMs += Date.now() - start;
|
||||
clearTimeout(attemptTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (finalError instanceof AttemptTimeout) {
|
||||
return {
|
||||
success: false,
|
||||
cause: "Timeout",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
cause: "MaxRetries",
|
||||
error: finalError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static RetryLimitExceeded = RetryLimitExceeded;
|
||||
static StopRetrying = StopRetrying;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactArgsForLogging } from "./exec.js";
|
||||
|
||||
describe("redactArgsForLogging", () => {
|
||||
it("masks the value following a credential flag", () => {
|
||||
expect(
|
||||
redactArgsForLogging(["login", "--username", "robot", "--password", "s3cr3t", "host:80"])
|
||||
).toEqual(["login", "--username", "robot", "--password", "[redacted]", "host:80"]);
|
||||
});
|
||||
|
||||
it("masks inline --flag=value form", () => {
|
||||
expect(redactArgsForLogging(["--token=abc123"])).toEqual(["--token=[redacted]"]);
|
||||
});
|
||||
|
||||
it("leaves non-credential args untouched", () => {
|
||||
expect(redactArgsForLogging(["push", "--tls-verify=false", "host:80/img"])).toEqual([
|
||||
"push",
|
||||
"--tls-verify=false",
|
||||
"host:80/img",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes undefined through", () => {
|
||||
expect(redactArgsForLogging(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { SimpleStructuredLogger } from "../utils/structuredLogger.js";
|
||||
import { type Output, type Result, x } from "tinyexec";
|
||||
|
||||
export class ExecResult {
|
||||
pid?: number;
|
||||
exitCode?: number;
|
||||
aborted: boolean;
|
||||
killed: boolean;
|
||||
|
||||
constructor(result: Result) {
|
||||
this.pid = result.pid;
|
||||
this.exitCode = result.exitCode;
|
||||
this.aborted = result.aborted;
|
||||
this.killed = result.killed;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
logger?: SimpleStructuredLogger;
|
||||
abortSignal?: AbortSignal;
|
||||
logOutput?: boolean;
|
||||
trimArgs?: boolean;
|
||||
neverThrow?: boolean;
|
||||
}
|
||||
|
||||
// Long-form flags whose value carries a credential - the following arg (or inline
|
||||
// `--flag=value`) is replaced before args are logged so it never reaches log sinks.
|
||||
const REDACTED_FLAGS = new Set([
|
||||
"--password",
|
||||
"--token",
|
||||
"--secret",
|
||||
"--access-token",
|
||||
"--registry-token",
|
||||
"--registry-password",
|
||||
"--api-key",
|
||||
]);
|
||||
|
||||
export function redactArgsForLogging(args?: string[]): string[] | undefined {
|
||||
if (!args) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return args.map((arg, index) => {
|
||||
const previous = index > 0 ? args[index - 1]?.trim() : undefined;
|
||||
if (previous && REDACTED_FLAGS.has(previous)) {
|
||||
return "[redacted]";
|
||||
}
|
||||
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
if (equalsIndex > 0 && REDACTED_FLAGS.has(arg.slice(0, equalsIndex).trim())) {
|
||||
return `${arg.slice(0, equalsIndex)}=[redacted]`;
|
||||
}
|
||||
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
export class Exec {
|
||||
private logger: SimpleStructuredLogger;
|
||||
private abortSignal: AbortSignal | undefined;
|
||||
|
||||
private logOutput: boolean;
|
||||
private trimArgs: boolean;
|
||||
private neverThrow: boolean;
|
||||
|
||||
constructor(opts: ExecOptions) {
|
||||
this.logger = opts.logger ?? new SimpleStructuredLogger("exec");
|
||||
this.abortSignal = opts.abortSignal;
|
||||
|
||||
this.logOutput = opts.logOutput ?? true;
|
||||
this.trimArgs = opts.trimArgs ?? true;
|
||||
this.neverThrow = opts.neverThrow ?? false;
|
||||
}
|
||||
|
||||
async x(
|
||||
command: string,
|
||||
args?: string[],
|
||||
opts?: { neverThrow?: boolean; ignoreAbort?: boolean }
|
||||
): Promise<Output> {
|
||||
const argsTrimmed = this.trimArgs ? args?.map((arg) => arg.trim()) : args;
|
||||
|
||||
const argsForLogging = redactArgsForLogging(args);
|
||||
const argsTrimmedForLogging = redactArgsForLogging(argsTrimmed);
|
||||
|
||||
const commandWithFirstArg = `${command}${argsTrimmedForLogging?.length ? ` ${argsTrimmedForLogging[0]}` : ""}`;
|
||||
this.logger.debug(`exec: ${commandWithFirstArg}`, {
|
||||
command,
|
||||
args: argsForLogging,
|
||||
argsTrimmed: argsTrimmedForLogging,
|
||||
});
|
||||
|
||||
const result = x(command, argsTrimmed, {
|
||||
signal: opts?.ignoreAbort ? undefined : this.abortSignal,
|
||||
// We don't use this as it doesn't cover killed and aborted processes
|
||||
// throwOnError: true,
|
||||
});
|
||||
|
||||
const output = await result;
|
||||
|
||||
const metadata = {
|
||||
command,
|
||||
argsRaw: argsForLogging,
|
||||
argsTrimmed: argsTrimmedForLogging,
|
||||
globalOpts: {
|
||||
trimArgs: this.trimArgs,
|
||||
neverThrow: this.neverThrow,
|
||||
hasAbortSignal: !!this.abortSignal,
|
||||
},
|
||||
localOpts: opts,
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
pid: result.pid,
|
||||
exitCode: result.exitCode,
|
||||
aborted: result.aborted,
|
||||
killed: result.killed,
|
||||
};
|
||||
|
||||
if (this.logOutput) {
|
||||
this.logger.debug(`output: ${commandWithFirstArg}`, metadata);
|
||||
}
|
||||
|
||||
if (this.neverThrow || opts?.neverThrow) {
|
||||
return output;
|
||||
}
|
||||
|
||||
if (result.aborted) {
|
||||
this.logger.error(`aborted: ${commandWithFirstArg}`, metadata);
|
||||
throw new ExecResult(result);
|
||||
}
|
||||
|
||||
if (result.killed) {
|
||||
this.logger.error(`killed: ${commandWithFirstArg}`, metadata);
|
||||
throw new ExecResult(result);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
this.logger.error(`non-zero exit: ${commandWithFirstArg}`, metadata);
|
||||
throw new ExecResult(result);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static Result = ExecResult;
|
||||
}
|
||||
|
||||
export { type Output };
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { IncomingMessage, RequestListener } from "node:http";
|
||||
|
||||
export async function getJsonBody(req: IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(safeJsonParse(body));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function safeJsonParse(text: string) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse JSON", { error, text });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpReply {
|
||||
constructor(private response: Parameters<RequestListener>[1]) {}
|
||||
|
||||
empty(status?: number) {
|
||||
if (this.hasReplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.response.writeHead(status ?? 200).end();
|
||||
}
|
||||
|
||||
text(text: string, status?: number, contentType?: string) {
|
||||
if (this.hasReplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.response
|
||||
.writeHead(status ?? 200, { "Content-Type": contentType || "text/plain" })
|
||||
.end(text.endsWith("\n") ? text : `${text}\n`);
|
||||
}
|
||||
|
||||
json(value: any, pretty?: boolean, status?: number) {
|
||||
if (this.hasReplied) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.text(
|
||||
JSON.stringify(value, undefined, pretty ? 2 : undefined),
|
||||
status ?? 200,
|
||||
"application/json"
|
||||
);
|
||||
}
|
||||
|
||||
get hasReplied() {
|
||||
return this.response.headersSent;
|
||||
}
|
||||
}
|
||||
|
||||
function getRandomInteger(min: number, max: number) {
|
||||
const intMin = Math.ceil(min);
|
||||
const intMax = Math.floor(max);
|
||||
return Math.floor(Math.random() * (intMax - intMin + 1)) + intMin;
|
||||
}
|
||||
|
||||
export function getRandomPortNumber() {
|
||||
return getRandomInteger(8000, 9999);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./backoff.js";
|
||||
export * from "./http.js";
|
||||
export * from "./exec.js";
|
||||
@@ -0,0 +1,104 @@
|
||||
// Slim shape of an authenticated runtime environment, structural and
|
||||
// independent of @trigger.dev/database. Carried across the auth boundary
|
||||
// (RBAC plugin contract → host webapp) so plugins can return all the
|
||||
// fields handlers consume without a follow-up DB lookup.
|
||||
//
|
||||
// This is hand-rolled rather than derived from `Prisma.RuntimeEnvironmentGetPayload`
|
||||
// because the contract package (@trigger.dev/plugins) is published while
|
||||
// @trigger.dev/database is private — and because callers of this type
|
||||
// genuinely use only a fraction of the columns Prisma would expose.
|
||||
//
|
||||
// If a downstream consumer needs a field that's not here:
|
||||
// - Used in the auth-cross-cutting hot path → add it
|
||||
// - Used in a service that already loads the env → fetch it there instead
|
||||
//
|
||||
// `concurrencyLimitBurstFactor` is a `Decimal(4,2)` in Postgres — values
|
||||
// are O(2.00) in practice; coerced to `number` here (lossless at this
|
||||
// scale, avoids dragging in Prisma's Decimal class via type imports).
|
||||
|
||||
// String-literal unions mirror the corresponding Prisma enums. Defining
|
||||
// them here keeps the contract structural (no @trigger.dev/database
|
||||
// import) while giving downstream consumers the same exact union they
|
||||
// expect when this value is passed to a Prisma column.
|
||||
export type RuntimeEnvironmentType = "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW";
|
||||
|
||||
export type RunEngineVersion = "V1" | "V2";
|
||||
|
||||
// Prisma's Decimal class. Accept it structurally so consumers (mostly
|
||||
// the webapp's `runtimeEnvironment.server.ts` model functions) can pass
|
||||
// raw Prisma rows without coercion. Plugins that don't have a Decimal
|
||||
// type at hand (cloud's Drizzle plugin) return plain `number`.
|
||||
type DecimalLike = { toNumber(): number };
|
||||
|
||||
export type AuthenticatedEnvironment = {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
apiKey: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
orgMemberId: string | null;
|
||||
parentEnvironmentId: string | null;
|
||||
branchName: string | null;
|
||||
archivedAt: Date | null;
|
||||
paused: boolean;
|
||||
shortcode: string;
|
||||
maximumConcurrencyLimit: number;
|
||||
concurrencyLimitBurstFactor: number | DecimalLike;
|
||||
// Prisma JSON column. Specific flags read it with their own narrower
|
||||
// types. Pass-through for legacy override paths in marqs / sharedQueue.
|
||||
builtInEnvironmentVariableOverrides: unknown;
|
||||
// Bookkeeping timestamps. Prisma rows always have them; non-Prisma
|
||||
// plugins can fill in with `new Date()` or whatever's appropriate.
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
project: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
externalRef: string;
|
||||
engine: RunEngineVersion;
|
||||
deletedAt: Date | null;
|
||||
defaultWorkerGroupId: string | null;
|
||||
// Same id as env.organizationId — present on Prisma's Project row
|
||||
// and read by deployment services that operate on the project alone.
|
||||
organizationId: string;
|
||||
// Build-server bookkeeping. Read by remote-image-builder when
|
||||
// creating Depot builds.
|
||||
builderProjectId: string | null;
|
||||
};
|
||||
|
||||
organization: {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
streamBasinName: string | null;
|
||||
maximumConcurrencyLimit: number | null;
|
||||
runsEnabled: boolean;
|
||||
maximumDevQueueSize: number | null;
|
||||
maximumDeployedQueueSize: number | null;
|
||||
// Per-org feature flags + rate-limit config. Loosely typed (Prisma
|
||||
// JSON) — handlers that care about specific keys read with their
|
||||
// own narrower types.
|
||||
featureFlags: unknown;
|
||||
apiRateLimiterConfig: unknown;
|
||||
batchRateLimitConfig: unknown;
|
||||
batchQueueConcurrencyConfig: unknown;
|
||||
};
|
||||
|
||||
// `user` is optional because most call sites only fetch `userId`.
|
||||
// Code paths that need user details (display name etc.) include it
|
||||
// explicitly in their Prisma query. The whole field is optional too
|
||||
// so admin construction sites that build env literals without it
|
||||
// satisfy the type.
|
||||
orgMember?: {
|
||||
userId: string;
|
||||
user?: { id: string; displayName: string | null; name: string | null };
|
||||
} | null;
|
||||
|
||||
// Optional + nullable: optional so admin routes that don't explicitly
|
||||
// include parentEnvironment satisfy the type; nullable so Prisma rows
|
||||
// with a null left-join result satisfy too.
|
||||
parentEnvironment?: { id: string; apiKey: string } | null;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BuildManifest, BuildTarget } from "../schemas/build.js";
|
||||
import type { Plugin } from "esbuild";
|
||||
import { ResolvedConfig } from "./resolvedConfig.js";
|
||||
|
||||
export function esbuildPlugin(plugin: Plugin, options: RegisterPluginOptions = {}): BuildExtension {
|
||||
return {
|
||||
name: plugin.name,
|
||||
onBuildStart(context) {
|
||||
context.registerPlugin(plugin, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildExtension {
|
||||
name: string;
|
||||
externalsForTarget?: (target: BuildTarget) => string[] | undefined;
|
||||
onBuildStart?: (context: BuildContext) => Promise<void> | void;
|
||||
onBuildComplete?: (
|
||||
context: BuildContext,
|
||||
manifest: BuildManifest
|
||||
) => Promise<undefined | void> | undefined | void;
|
||||
}
|
||||
|
||||
export interface BuildSpinner {
|
||||
stop: (message: string, code?: number) => void;
|
||||
message: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface BuildLogger {
|
||||
debug: (...args: unknown[]) => void;
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
progress: (message: string) => void;
|
||||
spinner: (message: string) => BuildSpinner;
|
||||
}
|
||||
|
||||
export interface BuildContext {
|
||||
target: BuildTarget;
|
||||
config: ResolvedConfig;
|
||||
workingDir: string;
|
||||
logger: BuildLogger;
|
||||
|
||||
addLayer(layer: BuildLayer): void;
|
||||
registerPlugin(plugin: Plugin, options?: RegisterPluginOptions): void;
|
||||
|
||||
/*
|
||||
* Resolve a path relative to the working directory
|
||||
*/
|
||||
resolvePath(path: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export interface BuildLayer {
|
||||
id: string;
|
||||
commands?: string[];
|
||||
files?: Record<string, string>;
|
||||
image?: {
|
||||
pkgs?: string[];
|
||||
instructions?: string[];
|
||||
};
|
||||
build?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
deploy?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
parentEnv?: Record<string, string | undefined>;
|
||||
secretEnv?: Record<string, string | undefined>;
|
||||
secretParentEnv?: Record<string, string | undefined>;
|
||||
override?: boolean;
|
||||
};
|
||||
dependencies?: Record<string, string>;
|
||||
conditions?: string[];
|
||||
}
|
||||
|
||||
export type PluginPlacement = "first" | "last";
|
||||
|
||||
export type RegisterPluginOptions = {
|
||||
target?: BuildTarget;
|
||||
placement?: PluginPlacement;
|
||||
};
|
||||
|
||||
export type RegisteredPlugin = RegisterPluginOptions & {
|
||||
plugin: Plugin;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const alwaysExternal = ["import-in-the-middle", "header-generator"];
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { dedupFlags } from "./flags.js";
|
||||
|
||||
describe("dedupFlags", () => {
|
||||
it("should keep single flags unchanged", () => {
|
||||
expect(dedupFlags("--verbose")).toBe("--verbose");
|
||||
expect(dedupFlags("-v")).toBe("-v");
|
||||
expect(dedupFlags("--debug=true")).toBe("--debug=true");
|
||||
});
|
||||
|
||||
it("should preserve multiple different flags", () => {
|
||||
expect(dedupFlags("--quiet --verbose")).toBe("--quiet --verbose");
|
||||
expect(dedupFlags("-v -q --log=debug")).toBe("-v -q --log=debug");
|
||||
});
|
||||
|
||||
it("should use last value when flags are duplicated", () => {
|
||||
expect(dedupFlags("--debug=false --debug=true")).toBe("--debug=true");
|
||||
expect(dedupFlags("--log=info --log=warn --log=error")).toBe("--log=error");
|
||||
});
|
||||
|
||||
it("should treat underscores as hyphens", () => {
|
||||
expect(dedupFlags("--debug_level=info")).toBe("--debug-level=info");
|
||||
expect(dedupFlags("--debug_level=info --debug-level=warn")).toBe("--debug-level=warn");
|
||||
});
|
||||
|
||||
it("should handle mix of flags with and without values", () => {
|
||||
expect(dedupFlags("--debug=false -v --debug=true")).toBe("-v --debug=true");
|
||||
expect(dedupFlags("-v --quiet -v")).toBe("--quiet -v");
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it("should handle empty string", () => {
|
||||
expect(dedupFlags("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle multiple spaces between flags", () => {
|
||||
expect(dedupFlags("--debug=false --verbose --debug=true")).toBe("--verbose --debug=true");
|
||||
});
|
||||
|
||||
it("should handle flags with empty values", () => {
|
||||
expect(dedupFlags("--path= --path=foo")).toBe("--path=foo");
|
||||
expect(dedupFlags("--path=foo --path=")).toBe("--path=");
|
||||
});
|
||||
|
||||
it("should preserve values containing equals signs", () => {
|
||||
expect(dedupFlags("--url=http://example.com?foo=bar")).toBe("--url=http://example.com?foo=bar");
|
||||
});
|
||||
|
||||
it("should handle flags with special characters", () => {
|
||||
expect(dedupFlags("--path=/usr/local --path=/home/user")).toBe("--path=/home/user");
|
||||
expect(dedupFlags('--name="John Doe" --name="Jane Doe"')).toBe('--name="Jane Doe"');
|
||||
});
|
||||
|
||||
it("should handle multiple flag variants", () => {
|
||||
const input = "--env=dev -v --env=prod --quiet -v --env=staging";
|
||||
expect(dedupFlags(input)).toBe("--quiet -v --env=staging");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Deduplicates command line flags by keeping only the last occurrence of each flag.
|
||||
* Preserves the order of the last occurrence of each flag.
|
||||
*
|
||||
* @param flags - A space-separated string of command line flags
|
||||
* @returns A space-separated string of deduplicated flags
|
||||
*
|
||||
* @example
|
||||
* // Single flags are preserved
|
||||
* dedupFlags("--quiet --verbose") // returns "--quiet --verbose"
|
||||
*
|
||||
* @example
|
||||
* // For duplicate flags, the last value wins and maintains its position
|
||||
* dedupFlags("--debug=false --log=info --debug=true") // returns "--log=info --debug=true"
|
||||
*
|
||||
* @example
|
||||
* // Mixing flags with and without values
|
||||
* dedupFlags("-v --log=debug -v") // returns "--log=debug -v"
|
||||
*/
|
||||
export function dedupFlags(flags: string): string {
|
||||
const seen = new Set<string>();
|
||||
const result: [string, string | boolean][] = [];
|
||||
|
||||
const pairs = flags
|
||||
.split(" ")
|
||||
.filter(Boolean) // Remove empty strings from multiple spaces
|
||||
.map((flag): [string, string | boolean] => {
|
||||
const equalIndex = flag.indexOf("=");
|
||||
if (equalIndex !== -1) {
|
||||
const key = flag.slice(0, equalIndex).replace(/_/g, "-");
|
||||
const value = flag.slice(equalIndex + 1);
|
||||
return [key, value];
|
||||
} else {
|
||||
return [flag.replace(/_/g, "-"), true];
|
||||
}
|
||||
});
|
||||
|
||||
// Process in reverse to keep last occurrence
|
||||
for (const [key, value] of pairs.reverse()) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.unshift([key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
return result.map(([key, value]) => (value === true ? key : `${key}=${value}`)).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./extensions.js";
|
||||
export * from "./resolvedConfig.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./externals.js";
|
||||
export * from "./flags.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type Defu } from "defu";
|
||||
import type { Prettify } from "ts-essentials";
|
||||
import { CompatibilityFlag, CompatibilityFlagFeatures, TriggerConfig } from "../config.js";
|
||||
import { BuildRuntime } from "../schemas/build.js";
|
||||
import { ResolveEnvironmentVariablesFunction } from "../types/index.js";
|
||||
|
||||
export type ResolvedConfig = Prettify<
|
||||
Defu<
|
||||
TriggerConfig,
|
||||
[
|
||||
{},
|
||||
{
|
||||
runtime: BuildRuntime;
|
||||
dirs: string[];
|
||||
tsconfig: string;
|
||||
build: {
|
||||
jsx: { factory: string; fragment: string; automatic: true };
|
||||
} & Omit<NonNullable<TriggerConfig["build"]>, "jsx">;
|
||||
compatibilityFlags: CompatibilityFlag[];
|
||||
features: CompatibilityFlagFeatures;
|
||||
},
|
||||
]
|
||||
> & {
|
||||
workingDir: string;
|
||||
workspaceDir: string;
|
||||
packageJsonPath: string;
|
||||
lockfilePath: string;
|
||||
configFile?: string;
|
||||
tsconfigPath?: string;
|
||||
resolveEnvVars?: ResolveEnvironmentVariablesFunction;
|
||||
instrumentedPackageNames?: string[];
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "url";
|
||||
import { BuildRuntime } from "../schemas/build.js";
|
||||
import { dedupFlags } from "./flags.js";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime;
|
||||
|
||||
export function binaryForRuntime(runtime: BuildRuntime): string {
|
||||
switch (runtime) {
|
||||
case "node":
|
||||
case "node-22":
|
||||
return "node";
|
||||
case "bun":
|
||||
return "bun";
|
||||
default:
|
||||
throw new Error(`Unsupported runtime ${runtime}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function execPathForRuntime(runtime: BuildRuntime): string {
|
||||
switch (runtime) {
|
||||
case "node":
|
||||
case "node-22":
|
||||
return process.execPath;
|
||||
case "bun":
|
||||
if (typeof process.env.BUN_INSTALL === "string") {
|
||||
return join(process.env.BUN_INSTALL, "bin", "bun");
|
||||
}
|
||||
|
||||
if (typeof process.env.BUN_INSTALL_BIN === "string") {
|
||||
return join(process.env.BUN_INSTALL_BIN, "bun");
|
||||
}
|
||||
|
||||
return join(homedir(), ".bun", "bin", "bun");
|
||||
default:
|
||||
throw new Error(`Unsupported runtime ${runtime}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecOptions = {
|
||||
loaderEntryPoint?: string;
|
||||
customConditions?: string[];
|
||||
};
|
||||
|
||||
export function execOptionsForRuntime(
|
||||
runtime: BuildRuntime,
|
||||
options: ExecOptions,
|
||||
additionalNodeOptions?: string
|
||||
): string {
|
||||
switch (runtime) {
|
||||
case "node":
|
||||
case "node-22": {
|
||||
const importEntryPoint = options.loaderEntryPoint
|
||||
? `--import=${pathToFileURL(options.loaderEntryPoint).href}`
|
||||
: undefined;
|
||||
|
||||
const conditions = options.customConditions?.map((condition) => `--conditions=${condition}`);
|
||||
|
||||
//later flags will win (after the dedupe)
|
||||
const flags = [
|
||||
process.env.NODE_OPTIONS,
|
||||
additionalNodeOptions,
|
||||
importEntryPoint,
|
||||
conditions,
|
||||
nodeRuntimeNeedsGlobalWebCryptoFlag() ? "--experimental-global-webcrypto" : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.flat()
|
||||
.join(" ");
|
||||
|
||||
return dedupFlags(flags);
|
||||
}
|
||||
case "bun": {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if we are using node v18, since we don't support lower than 18, and we only need to enable the flag for v18
|
||||
function nodeRuntimeNeedsGlobalWebCryptoFlag(): boolean {
|
||||
try {
|
||||
return process.versions.node.startsWith("18.");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectRuntimeVersion(): string | undefined {
|
||||
try {
|
||||
// Check if we're running under Bun
|
||||
const isBun = typeof process.versions.bun === "string";
|
||||
|
||||
if (isBun) {
|
||||
return process.versions.bun;
|
||||
}
|
||||
|
||||
// Otherwise, return Node.js version
|
||||
return process.versions.node;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { ClockAPI } from "./clock/index.js";
|
||||
/** Entrypoint for clock API */
|
||||
export const clock = ClockAPI.getInstance();
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Contains two parts: the first part is the seconds, the second part is the nanoseconds.
|
||||
*
|
||||
*/
|
||||
export type ClockTime = [number, number];
|
||||
|
||||
export interface Clock {
|
||||
preciseNow(): ClockTime;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function calculateDurationInMs(start: ClockTime, end: ClockTime): number {
|
||||
const [startSeconds, startNanoseconds] = start;
|
||||
const [endSeconds, endNanoseconds] = end;
|
||||
|
||||
const seconds = endSeconds - startSeconds;
|
||||
const nanoseconds = endNanoseconds - startNanoseconds;
|
||||
|
||||
return Math.floor(seconds * 1000 + nanoseconds / 1000000);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const API_NAME = "clock";
|
||||
|
||||
import { getGlobal, registerGlobal } from "../utils/globals.js";
|
||||
import type { Clock, ClockTime } from "./clock.js";
|
||||
import { SimpleClock } from "./simpleClock.js";
|
||||
|
||||
const SIMPLE_CLOCK = new SimpleClock();
|
||||
|
||||
export class ClockAPI {
|
||||
private static _instance?: ClockAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ClockAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new ClockAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public setGlobalClock(clock: Clock): boolean {
|
||||
return registerGlobal(API_NAME, clock);
|
||||
}
|
||||
|
||||
public preciseNow(): ClockTime {
|
||||
return this.#getClock().preciseNow();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.#getClock().reset();
|
||||
}
|
||||
|
||||
#getClock(): Clock {
|
||||
return getGlobal(API_NAME) ?? SIMPLE_CLOCK;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { PreciseDate } from "@google-cloud/precise-date";
|
||||
import type { Clock, ClockTime } from "./clock.js";
|
||||
|
||||
export type PreciseWallClockOptions = {
|
||||
origin?: ClockTime;
|
||||
now?: PreciseDate;
|
||||
};
|
||||
|
||||
export class PreciseWallClock implements Clock {
|
||||
private _origin: {
|
||||
clockTime: ClockTime;
|
||||
preciseDate: PreciseDate;
|
||||
};
|
||||
|
||||
get #originClockTime() {
|
||||
return this._origin.clockTime;
|
||||
}
|
||||
|
||||
get #originPreciseDate() {
|
||||
return this._origin.preciseDate;
|
||||
}
|
||||
|
||||
constructor(options: PreciseWallClockOptions = {}) {
|
||||
this._origin = {
|
||||
clockTime: options.origin ?? process.hrtime(),
|
||||
preciseDate: options.now ?? new PreciseDate(),
|
||||
};
|
||||
}
|
||||
|
||||
preciseNow(): [number, number] {
|
||||
const elapsedHrTime = process.hrtime(this.#originClockTime);
|
||||
const elapsedNanoseconds = BigInt(elapsedHrTime[0]) * BigInt(1e9) + BigInt(elapsedHrTime[1]);
|
||||
|
||||
const preciseDate = new PreciseDate(this.#originPreciseDate.getFullTime() + elapsedNanoseconds);
|
||||
const dateStruct = preciseDate.toStruct();
|
||||
|
||||
return [dateStruct.seconds, dateStruct.nanos];
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._origin = {
|
||||
clockTime: process.hrtime(),
|
||||
preciseDate: new PreciseDate(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PreciseDate } from "@google-cloud/precise-date";
|
||||
import type { Clock } from "./clock.js";
|
||||
|
||||
export class SimpleClock implements Clock {
|
||||
preciseNow(): [number, number] {
|
||||
const now = new PreciseDate();
|
||||
const nowStruct = now.toStruct();
|
||||
|
||||
return [nowStruct.seconds, nowStruct.nanos];
|
||||
}
|
||||
|
||||
reset() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import type { Instrumentation } from "@opentelemetry/instrumentation";
|
||||
import type { SpanExporter } from "@opentelemetry/sdk-trace-base";
|
||||
import type { MetricReader, PushMetricExporter } from "@opentelemetry/sdk-metrics";
|
||||
import type { BuildExtension } from "./build/extensions.js";
|
||||
import type {
|
||||
AnyOnFailureHookFunction,
|
||||
AnyOnInitHookFunction,
|
||||
AnyOnStartHookFunction,
|
||||
AnyOnSuccessHookFunction,
|
||||
BuildRuntime,
|
||||
RetryOptions,
|
||||
} from "./index.js";
|
||||
import type { LogLevel } from "./logger/taskLogger.js";
|
||||
import type { MachinePresetName } from "./schemas/common.js";
|
||||
import type { LogRecordExporter } from "@opentelemetry/sdk-logs";
|
||||
import type { Resource } from "@opentelemetry/resources";
|
||||
|
||||
export type CompatibilityFlag = "run_engine_v2";
|
||||
|
||||
export type CompatibilityFlagFeatures = {
|
||||
[key in CompatibilityFlag]: boolean;
|
||||
};
|
||||
|
||||
type ProcessKeepAlive =
|
||||
| boolean
|
||||
| {
|
||||
enabled: boolean;
|
||||
/**
|
||||
* The maximum number of executions per process. If the process has run more than this number of times, it will be killed.
|
||||
*
|
||||
* @default 50
|
||||
*/
|
||||
maxExecutionsPerProcess?: number;
|
||||
|
||||
/**
|
||||
* The maximum number of processes to keep alive in dev.
|
||||
*
|
||||
* @default 25
|
||||
*/
|
||||
devMaxPoolSize?: number;
|
||||
};
|
||||
|
||||
export type TriggerConfig = {
|
||||
/**
|
||||
* @default "node"
|
||||
*/
|
||||
runtime?: BuildRuntime;
|
||||
|
||||
/**
|
||||
* Specify the project ref for your trigger.dev tasks. This is the project ref that you get when you create a new project in the trigger.dev dashboard.
|
||||
*/
|
||||
project: string;
|
||||
|
||||
/**
|
||||
* Specify the directories that contain your trigger.dev tasks. This is useful if you have multiple directories that contain tasks.
|
||||
*
|
||||
* We automatically detect directories named `trigger` to be task directories. You can override this behavior by specifying the directories here.
|
||||
*
|
||||
* @see @see https://trigger.dev/docs/config/config-file#dirs
|
||||
*/
|
||||
dirs?: string[];
|
||||
|
||||
/**
|
||||
* Specify glob patterns to ignore when detecting task files. By default we ignore:
|
||||
*
|
||||
* - *.test.ts
|
||||
* - *.spec.ts
|
||||
* - *.test.mts
|
||||
* - *.spec.mts
|
||||
* - *.test.cts
|
||||
* - *.spec.cts
|
||||
* - *.test.js
|
||||
* - *.spec.js
|
||||
* - *.test.mjs
|
||||
* - *.spec.mjs
|
||||
* - *.test.cjs
|
||||
* - *.spec.cjs
|
||||
*
|
||||
*/
|
||||
ignorePatterns?: string[];
|
||||
|
||||
/**
|
||||
* Instrumentations to use for OpenTelemetry. This is useful if you want to add custom instrumentations to your tasks.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#instrumentations
|
||||
*
|
||||
* @deprecated Use the `telemetry.instrumentations` option instead.
|
||||
*/
|
||||
instrumentations?: Array<Instrumentation>;
|
||||
|
||||
telemetry?: {
|
||||
/**
|
||||
* Instrumentations to use for OpenTelemetry. This is useful if you want to add custom instrumentations to your tasks.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#instrumentations
|
||||
*/
|
||||
instrumentations?: Array<Instrumentation>;
|
||||
|
||||
/**
|
||||
* Exporters to use for OpenTelemetry. This is useful if you want to add custom exporters to your tasks.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#exporters
|
||||
*/
|
||||
exporters?: Array<SpanExporter>;
|
||||
|
||||
/**
|
||||
* Log exporters to use for OpenTelemetry. This is useful if you want to add custom log exporters to your tasks.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#exporters
|
||||
*/
|
||||
logExporters?: Array<LogRecordExporter>;
|
||||
|
||||
/**
|
||||
* Metric exporters to use for OpenTelemetry. This is useful if you want to export metrics to external services.
|
||||
* Each exporter is automatically wrapped in a PeriodicExportingMetricReader.
|
||||
*
|
||||
* For more control over the reader configuration, use `metricReaders` instead.
|
||||
*/
|
||||
metricExporters?: Array<PushMetricExporter>;
|
||||
|
||||
/**
|
||||
* Metric readers for OpenTelemetry. Add custom metric readers to export
|
||||
* metrics to external services alongside the default Trigger.dev exporter.
|
||||
*/
|
||||
metricReaders?: Array<MetricReader>;
|
||||
|
||||
/**
|
||||
* Resource to use for OpenTelemetry. This is useful if you want to add custom resources to your tasks.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#resource
|
||||
*/
|
||||
resource?: Resource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Specify a custom path to your tsconfig file. This is useful if you have a custom tsconfig file that you want to use.
|
||||
*/
|
||||
tsconfig?: string;
|
||||
|
||||
/**
|
||||
* Specify the global retry options for your tasks. You can override this on a per-task basis.
|
||||
*
|
||||
* @see https://trigger.dev/docs/tasks/overview#retry-options
|
||||
*/
|
||||
retries?: {
|
||||
enabledInDev?: boolean;
|
||||
default?: RetryOptions;
|
||||
};
|
||||
compatibilityFlags?: Array<CompatibilityFlag>;
|
||||
|
||||
/**
|
||||
* The default machine preset to use for your deployed trigger.dev tasks. You can override this on a per-task basis.
|
||||
* @default "small-1x"
|
||||
*
|
||||
* @see https://trigger.dev/docs/machines
|
||||
*/
|
||||
machine?: MachinePresetName;
|
||||
|
||||
/**
|
||||
* Set the log level for the logger. Defaults to "info", so you will see "log", "info", "warn", and "error" messages, but not "debug" messages.
|
||||
*
|
||||
* We automatically set the logLevel to "debug" during test runs
|
||||
*
|
||||
* @default "info"
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
|
||||
/**
|
||||
* The maximum duration in compute-time seconds that a task run is allowed to run. If the task run exceeds this duration, it will be stopped.
|
||||
*
|
||||
* Minimum value is 5 seconds
|
||||
*
|
||||
* Setting this value will effect all tasks in the project.
|
||||
*
|
||||
* @see https://trigger.dev/docs/tasks/overview#maxduration-option
|
||||
*/
|
||||
maxDuration: number;
|
||||
|
||||
/**
|
||||
* Set a default time-to-live (TTL) for all task runs in the project. If a run is not executed within this time, it will be removed from the queue and never execute.
|
||||
*
|
||||
* This can be a string like "1h" (1 hour), "30m" (30 minutes), "1d" (1 day), or a number of seconds.
|
||||
*
|
||||
* You can override this on a per-task basis by setting the `ttl` option on the task definition, or per-trigger by setting the `ttl` option when triggering.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* export default defineConfig({
|
||||
* project: "my-project",
|
||||
* maxDuration: 3600,
|
||||
* ttl: "1h",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
ttl?: string | number;
|
||||
|
||||
/**
|
||||
* Enable console logging while running the dev CLI. This will print out logs from console.log, console.warn, and console.error. By default all logs are sent to the trigger.dev backend, and not logged to the console.
|
||||
*/
|
||||
enableConsoleLogging?: boolean;
|
||||
|
||||
/**
|
||||
* Disable the console interceptor. This will prevent logs from being sent to the trigger.dev backend.
|
||||
*/
|
||||
disableConsoleInterceptor?: boolean;
|
||||
|
||||
build?: {
|
||||
/**
|
||||
* Add custom conditions to the esbuild build. For example, if you are importing `ai/rsc`, you'll need to add "react-server" condition.
|
||||
*
|
||||
* By default we add the following conditions:
|
||||
*
|
||||
* - "trigger.dev"
|
||||
* - "module"
|
||||
* - "node"
|
||||
*/
|
||||
conditions?: string[];
|
||||
|
||||
/**
|
||||
* Add custom build extensions to the build process.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#extensions
|
||||
*/
|
||||
extensions?: BuildExtension[];
|
||||
|
||||
/**
|
||||
* External dependencies to exclude from the bundle. This is useful if you want to keep some dependencies as external, and not bundle them with your code.
|
||||
*
|
||||
* @see https://trigger.dev/docs/config/config-file#external
|
||||
*/
|
||||
external?: string[];
|
||||
|
||||
/**
|
||||
* This still works but use `autoDetectExternal` instead.
|
||||
*
|
||||
* @deprecated (use autoDetectExternal instead)
|
||||
*/
|
||||
experimental_autoDetectExternal?: boolean;
|
||||
|
||||
/**
|
||||
* Automatically detect dependencies that shouldn't be bundled and mark them as external. For example, native modules.
|
||||
*
|
||||
* Turn this off if you are having issues and want to manually specify all `external` dependencies.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoDetectExternal?: boolean;
|
||||
|
||||
/**
|
||||
* This still works but use `keepNames` instead.
|
||||
*
|
||||
* @deprecated (use keepNames instead)
|
||||
*/
|
||||
experimental_keepNames?: boolean;
|
||||
|
||||
/* Set to false to minify the original names of functions and classes in the bundle.
|
||||
* This can make bundles smaller at the cost of compatibility with frameworks that rely on function/class/variable names.
|
||||
*
|
||||
* @link https://esbuild.github.io/api/#keep-names
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
keepNames?: boolean;
|
||||
|
||||
/**
|
||||
* This still works but use `minify` instead.
|
||||
*
|
||||
* @deprecated (use minify instead)
|
||||
*/
|
||||
experimental_minify?: boolean;
|
||||
|
||||
/**
|
||||
* **WARNING: This is an experimental feature and might be removed in a future version.**
|
||||
*
|
||||
* "Minification is not safe for 100% of all JavaScript code" - esbuild docs
|
||||
*
|
||||
* Minify the generated code to help decrease bundle size. This may break stuff.
|
||||
*
|
||||
* @link https://esbuild.github.io/api/#minify
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
minify?: boolean;
|
||||
|
||||
jsx?: {
|
||||
/**
|
||||
* @default "React.createElement"
|
||||
*/
|
||||
factory?: string;
|
||||
/**
|
||||
* @default "React.Fragment"
|
||||
*/
|
||||
fragment?: string;
|
||||
|
||||
/**
|
||||
* @default true
|
||||
* @description Set the esbuild jsx option to automatic. Set this to false if you aren't using React.
|
||||
* @see https://esbuild.github.io/api/#jsx
|
||||
*/
|
||||
automatic?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
deploy?: {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This still works but use `processKeepAlive` instead.
|
||||
*
|
||||
* @deprecated (use processKeepAlive instead)
|
||||
*/
|
||||
experimental_processKeepAlive?: ProcessKeepAlive;
|
||||
|
||||
/**
|
||||
* @default false
|
||||
* @description Keep the process alive after the task has finished running so the next task doesn't have to wait for the process to start up again.
|
||||
*
|
||||
* Note that the process could be killed at any time, and we don't make any guarantees about the process being alive for a certain amount of time
|
||||
*/
|
||||
processKeepAlive?: ProcessKeepAlive;
|
||||
|
||||
/**
|
||||
* @default true
|
||||
* @description If set to true when running the dev CLI, the current working directory will be set to where the command is run from.
|
||||
*
|
||||
* Setting this to `false` will set the current working directory to the build directory.
|
||||
* This more closely matches the behavior of the CLI when running in production and is highly recommended.
|
||||
*
|
||||
* This impacts the value of process.cwd() in your task code.
|
||||
*/
|
||||
legacyDevProcessCwdBehaviour?: boolean;
|
||||
|
||||
/**
|
||||
* @deprecated Use `dirs` instead
|
||||
*/
|
||||
triggerDirectories?: string[];
|
||||
|
||||
/**
|
||||
* @deprecated Use the `additionalPackages` extension instead.
|
||||
*/
|
||||
additionalPackages?: string[];
|
||||
|
||||
/**
|
||||
* @deprecated Use the `additionalFiles` extension instead.
|
||||
*/
|
||||
additionalFiles?: string[];
|
||||
|
||||
/**
|
||||
* @deprecated Dependencies are now bundled by default. If you want to exclude some dependencies from the bundle, use the `build.external` option.
|
||||
*/
|
||||
dependenciesToBundle?: Array<string | RegExp>;
|
||||
|
||||
/**
|
||||
* @deprecated Use `tsconfig` instead.
|
||||
*/
|
||||
tsconfigPath?: string;
|
||||
|
||||
/**
|
||||
* CA Cert file to be added to NODE_EXTRA_CA_CERT environment variable, useful in use with self signed cert in the trigger.dev environment.
|
||||
*
|
||||
* @example "./certs/ca.crt"
|
||||
* Note: must start with "./" and be relative to the project root.
|
||||
*
|
||||
*/
|
||||
extraCACerts?: string;
|
||||
|
||||
/**
|
||||
* Run before a task is executed, for all tasks. This is useful for setting up any global state that is needed for all tasks.
|
||||
*
|
||||
* @deprecated, please use tasks.init instead
|
||||
*/
|
||||
init?: AnyOnInitHookFunction;
|
||||
|
||||
/**
|
||||
* onSuccess is called after the run function has successfully completed.
|
||||
*
|
||||
* @deprecated, please use tasks.onSuccess instead
|
||||
*/
|
||||
onSuccess?: AnyOnSuccessHookFunction;
|
||||
|
||||
/**
|
||||
* onFailure is called after a task run has failed (meaning the run function threw an error and won't be retried anymore)
|
||||
*
|
||||
* @deprecated, please use tasks.onFailure instead
|
||||
*/
|
||||
onFailure?: AnyOnFailureHookFunction;
|
||||
|
||||
/**
|
||||
* onStart is called the first time a task is executed in a run (not before every retry)
|
||||
*
|
||||
* @deprecated, please use tasks.onStart instead
|
||||
*/
|
||||
onStart?: AnyOnStartHookFunction;
|
||||
|
||||
/**
|
||||
* @deprecated Use a custom build extension to add post install commands
|
||||
*/
|
||||
postInstall?: string;
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import type * as logsAPI from "@opentelemetry/api-logs";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import util from "node:util";
|
||||
import { iconStringForSeverity } from "./icons.js";
|
||||
import { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
|
||||
import { flattenAttributes } from "./utils/flattenAttributes.js";
|
||||
import type { ClockTime } from "./clock/clock.js";
|
||||
import { clock } from "./clock-api.js";
|
||||
|
||||
export class ConsoleInterceptor {
|
||||
constructor(
|
||||
private readonly logger: logsAPI.Logger,
|
||||
private readonly sendToStdIO: boolean,
|
||||
private readonly interceptingDisabled: boolean,
|
||||
private readonly maxAttributeCount?: number
|
||||
) {}
|
||||
|
||||
// Intercept the console and send logs to the OpenTelemetry logger
|
||||
// during the execution of the callback
|
||||
async intercept<T>(console: Console, callback: () => Promise<T>): Promise<T> {
|
||||
if (this.interceptingDisabled) {
|
||||
return await callback();
|
||||
}
|
||||
|
||||
// Save the original console methods
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
// Override the console methods
|
||||
console.log = this.log.bind(this);
|
||||
console.info = this.info.bind(this);
|
||||
console.warn = this.warn.bind(this);
|
||||
console.error = this.error.bind(this);
|
||||
console.debug = this.debug.bind(this);
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
// Restore the original console methods
|
||||
console.log = originalConsole.log;
|
||||
console.info = originalConsole.info;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
console.debug = originalConsole.debug;
|
||||
}
|
||||
}
|
||||
|
||||
debug(...args: unknown[]): void {
|
||||
this.#handleLog(SeverityNumber.DEBUG, this.#getTimestampInHrTime(), "Debug", ...args);
|
||||
}
|
||||
|
||||
log(...args: unknown[]): void {
|
||||
this.#handleLog(SeverityNumber.INFO, this.#getTimestampInHrTime(), "Log", ...args);
|
||||
}
|
||||
|
||||
info(...args: unknown[]): void {
|
||||
this.#handleLog(SeverityNumber.INFO, this.#getTimestampInHrTime(), "Info", ...args);
|
||||
}
|
||||
|
||||
warn(...args: unknown[]): void {
|
||||
this.#handleLog(SeverityNumber.WARN, this.#getTimestampInHrTime(), "Warn", ...args);
|
||||
}
|
||||
|
||||
error(...args: unknown[]): void {
|
||||
this.#handleLog(SeverityNumber.ERROR, this.#getTimestampInHrTime(), "Error", ...args);
|
||||
}
|
||||
|
||||
#handleLog(
|
||||
severityNumber: SeverityNumber,
|
||||
timestamp: ClockTime,
|
||||
severityText: string,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
const body = util.format(...args);
|
||||
|
||||
if (this.sendToStdIO) {
|
||||
if (severityNumber === SeverityNumber.ERROR) {
|
||||
process.stderr.write(body);
|
||||
} else {
|
||||
process.stdout.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = tryParseJSON(body);
|
||||
|
||||
if (parsed.ok) {
|
||||
this.logger.emit({
|
||||
severityNumber,
|
||||
severityText,
|
||||
body: getLogMessage(parsed.value, severityText),
|
||||
attributes: {
|
||||
...this.#getAttributes(severityNumber),
|
||||
...flattenAttributes(parsed.value, undefined, this.maxAttributeCount),
|
||||
},
|
||||
timestamp,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.emit({
|
||||
severityNumber,
|
||||
severityText,
|
||||
body,
|
||||
attributes: this.#getAttributes(severityNumber),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
#getTimestampInHrTime(): ClockTime {
|
||||
return clock.preciseNow();
|
||||
}
|
||||
|
||||
#getAttributes(severityNumber: SeverityNumber): logsAPI.LogAttributes {
|
||||
const icon = iconStringForSeverity(severityNumber);
|
||||
let result: logsAPI.LogAttributes = {};
|
||||
|
||||
if (icon !== undefined) {
|
||||
result[SemanticInternalAttributes.STYLE_ICON] = icon;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function getLogMessage(value: Record<string, unknown>, fallback: string): string {
|
||||
if (typeof value["message"] === "string") {
|
||||
return value["message"];
|
||||
}
|
||||
|
||||
if (typeof value["msg"] === "string") {
|
||||
return value["msg"];
|
||||
}
|
||||
|
||||
if (typeof value["body"] === "string") {
|
||||
return value["body"];
|
||||
}
|
||||
|
||||
if (typeof value["error"] === "string") {
|
||||
return value["error"];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function tryParseJSON(
|
||||
value: string
|
||||
): { ok: true; value: Record<string, unknown> } | { ok: false; value: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
return { ok: true, value: parsed };
|
||||
}
|
||||
|
||||
return { ok: false, value };
|
||||
} catch (_e) {
|
||||
return { ok: false, value };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DuplicateTaskIdsError } from "./errors.js";
|
||||
|
||||
describe("DuplicateTaskIdsError", () => {
|
||||
test("is an Error with a stable name and the collisions attached", () => {
|
||||
const collisions = [{ id: "foo", filePaths: ["src/a.ts", "src/b.ts"] }];
|
||||
const error = new DuplicateTaskIdsError(collisions);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe("DuplicateTaskIdsError");
|
||||
expect(error.collisions).toEqual(collisions);
|
||||
});
|
||||
|
||||
test("names the id and both files when an id is defined in two files", () => {
|
||||
const error = new DuplicateTaskIdsError([{ id: "foo", filePaths: ["src/a.ts", "src/b.ts"] }]);
|
||||
|
||||
expect(error.message).toContain('"foo"');
|
||||
expect(error.message).toContain("src/a.ts");
|
||||
expect(error.message).toContain("src/b.ts");
|
||||
});
|
||||
|
||||
test("collapses identical file paths into a single 'more than once' location", () => {
|
||||
const error = new DuplicateTaskIdsError([
|
||||
{ id: "foo", filePaths: ["src/dupe.ts", "src/dupe.ts"] },
|
||||
]);
|
||||
|
||||
expect(error.message).toContain("more than once in src/dupe.ts");
|
||||
// The same path must not be listed twice.
|
||||
expect(error.message.match(/src\/dupe\.ts/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("lists every collision when multiple ids clash", () => {
|
||||
const error = new DuplicateTaskIdsError([
|
||||
{ id: "foo", filePaths: ["src/a.ts", "src/b.ts"] },
|
||||
{ id: "bar", filePaths: ["src/c.ts", "src/d.ts"] },
|
||||
]);
|
||||
|
||||
expect(error.message).toContain('"foo"');
|
||||
expect(error.message).toContain('"bar"');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { HeartbeatsAPI } from "./heartbeats/api.js";
|
||||
/** Entrypoint for heartbeats API */
|
||||
export const heartbeats = HeartbeatsAPI.getInstance();
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import type { HeartbeatsManager } from "./types.js";
|
||||
|
||||
const API_NAME = "heartbeats";
|
||||
|
||||
class NoopHeartbeatsManager implements HeartbeatsManager {
|
||||
startHeartbeat(id: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
return;
|
||||
}
|
||||
|
||||
async yield() {
|
||||
return;
|
||||
}
|
||||
|
||||
get lastHeartbeat(): Date | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
reset() {}
|
||||
}
|
||||
|
||||
const NOOP_HEARTBEATS_MANAGER = new NoopHeartbeatsManager();
|
||||
|
||||
export class HeartbeatsAPI implements HeartbeatsManager {
|
||||
private static _instance?: HeartbeatsAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): HeartbeatsAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new HeartbeatsAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public setGlobalManager(manager: HeartbeatsManager): boolean {
|
||||
return registerGlobal(API_NAME, manager);
|
||||
}
|
||||
|
||||
public disable() {
|
||||
unregisterGlobal(API_NAME);
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.#getManager().reset();
|
||||
this.disable();
|
||||
}
|
||||
|
||||
public get lastHeartbeat(): Date | undefined {
|
||||
return this.#getManager().lastHeartbeat;
|
||||
}
|
||||
|
||||
public startHeartbeat(id: string) {
|
||||
return this.#getManager().startHeartbeat(id);
|
||||
}
|
||||
|
||||
public stopHeartbeat() {
|
||||
return this.#getManager().stopHeartbeat();
|
||||
}
|
||||
|
||||
public yield() {
|
||||
return this.#getManager().yield();
|
||||
}
|
||||
|
||||
#getManager(): HeartbeatsManager {
|
||||
return getGlobal(API_NAME) ?? NOOP_HEARTBEATS_MANAGER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { setInterval, setTimeout } from "node:timers/promises";
|
||||
import { tryCatch } from "../tryCatch.js";
|
||||
import type { HeartbeatsManager } from "./types.js";
|
||||
|
||||
export class StandardHeartbeatsManager implements HeartbeatsManager {
|
||||
private listener: ((id: string) => Promise<void>) | undefined = undefined;
|
||||
private currentAbortController: AbortController | undefined = undefined;
|
||||
private lastHeartbeatYieldTime: number | undefined = undefined;
|
||||
private lastHeartbeatDate: Date | undefined = undefined;
|
||||
|
||||
constructor(private readonly intervalInMs: number) {}
|
||||
|
||||
registerListener(callback: (id: string) => Promise<void>) {
|
||||
this.listener = callback;
|
||||
}
|
||||
|
||||
async yield(): Promise<void> {
|
||||
if (!this.lastHeartbeatYieldTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only call setImmediate if we haven't yielded in the last interval
|
||||
if (Date.now() - this.lastHeartbeatYieldTime >= this.intervalInMs) {
|
||||
// await setImmediate();
|
||||
await setTimeout(24);
|
||||
|
||||
this.lastHeartbeatYieldTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
startHeartbeat(id: string) {
|
||||
this.stopHeartbeat();
|
||||
this.currentAbortController = new AbortController();
|
||||
this.lastHeartbeatYieldTime = Date.now();
|
||||
|
||||
// Ignore errors as we expect them to be thrown when the heartbeat is stopped
|
||||
this.startHeartbeatLoop(id, this.currentAbortController.signal).catch((error) => {});
|
||||
}
|
||||
|
||||
private async startHeartbeatLoop(id: string, signal: AbortSignal) {
|
||||
try {
|
||||
for await (const _ of setInterval(this.intervalInMs, undefined, {
|
||||
signal,
|
||||
})) {
|
||||
if (this.listener) {
|
||||
const [error] = await tryCatch(this.listener(id));
|
||||
this.lastHeartbeatDate = new Date();
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to send HEARTBEAT message", { error: String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore errors as we expect them to be thrown when the heartbeat is stopped
|
||||
// And since we tryCatch inside the loop, we don't need to handle any other errors here
|
||||
}
|
||||
}
|
||||
|
||||
stopHeartbeat(): void {
|
||||
this.currentAbortController?.abort();
|
||||
}
|
||||
|
||||
get lastHeartbeat(): Date | undefined {
|
||||
return this.lastHeartbeatDate;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.stopHeartbeat();
|
||||
this.lastHeartbeatDate = undefined;
|
||||
this.lastHeartbeatYieldTime = undefined;
|
||||
this.currentAbortController = undefined;
|
||||
|
||||
// NOTE: Don't reset the listener, it's really just a single global callback,
|
||||
// but because of the structure of the dev/managed-run-worker and the ZodIpc constructor,
|
||||
// we have to create the StandardHeartbeatsManager instance before the ZodIpc instance is created.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface HeartbeatsManager {
|
||||
startHeartbeat(id: string): void;
|
||||
stopHeartbeat(): void;
|
||||
yield(): Promise<void>;
|
||||
reset(): void;
|
||||
|
||||
get lastHeartbeat(): Date | undefined;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
|
||||
export function iconStringForSeverity(severityNumber: SeverityNumber) {
|
||||
switch (severityNumber) {
|
||||
case SeverityNumber.UNSPECIFIED:
|
||||
return undefined;
|
||||
case SeverityNumber.TRACE:
|
||||
case SeverityNumber.TRACE2:
|
||||
case SeverityNumber.TRACE3:
|
||||
case SeverityNumber.TRACE4:
|
||||
return "trace";
|
||||
case SeverityNumber.DEBUG:
|
||||
case SeverityNumber.DEBUG2:
|
||||
case SeverityNumber.DEBUG3:
|
||||
case SeverityNumber.DEBUG4:
|
||||
return "debug";
|
||||
case SeverityNumber.INFO:
|
||||
case SeverityNumber.INFO2:
|
||||
case SeverityNumber.INFO3:
|
||||
case SeverityNumber.INFO4:
|
||||
return "info";
|
||||
case SeverityNumber.WARN:
|
||||
case SeverityNumber.WARN2:
|
||||
case SeverityNumber.WARN3:
|
||||
case SeverityNumber.WARN4:
|
||||
return "warn";
|
||||
case SeverityNumber.ERROR:
|
||||
case SeverityNumber.ERROR2:
|
||||
case SeverityNumber.ERROR3:
|
||||
case SeverityNumber.ERROR4:
|
||||
return "error";
|
||||
case SeverityNumber.FATAL:
|
||||
case SeverityNumber.FATAL2:
|
||||
case SeverityNumber.FATAL3:
|
||||
case SeverityNumber.FATAL4:
|
||||
return "fatal";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { IdempotencyKeyCatalogAPI } from "./idempotency-key-catalog/index.js";
|
||||
/** Entrypoint for idempotency key catalog API */
|
||||
export const idempotencyKeyCatalog = IdempotencyKeyCatalogAPI.getInstance();
|
||||
@@ -0,0 +1,12 @@
|
||||
export type IdempotencyKeyScope = "run" | "attempt" | "global";
|
||||
|
||||
export type IdempotencyKeyOptions = {
|
||||
key: string;
|
||||
scope: IdempotencyKeyScope;
|
||||
};
|
||||
|
||||
export interface IdempotencyKeyCatalog {
|
||||
registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void;
|
||||
getKeyOptions(hash: string): IdempotencyKeyOptions | undefined;
|
||||
clear(): void;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";
|
||||
|
||||
describe("InMemoryIdempotencyKeyCatalog", () => {
|
||||
it("stores and retrieves options", () => {
|
||||
const catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
const options = { key: "my-key", scope: "global" as const };
|
||||
|
||||
catalog.registerKeyOptions("hash1", options);
|
||||
|
||||
expect(catalog.getKeyOptions("hash1")).toEqual(options);
|
||||
});
|
||||
|
||||
it("returns undefined for non-existent keys", () => {
|
||||
const catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
|
||||
expect(catalog.getKeyOptions("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates options when registering the same hash twice", () => {
|
||||
const catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
|
||||
catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
|
||||
catalog.registerKeyOptions("hash1", { key: "key1-updated", scope: "run" });
|
||||
|
||||
expect(catalog.getKeyOptions("hash1")).toEqual({ key: "key1-updated", scope: "run" });
|
||||
});
|
||||
|
||||
it("retains every entry regardless of count (no eviction)", () => {
|
||||
const catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
const count = 5000;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
catalog.registerKeyOptions(`hash${i}`, { key: `key${i}`, scope: "global" });
|
||||
}
|
||||
|
||||
// The very first entry must still be present — nothing is silently evicted.
|
||||
expect(catalog.getKeyOptions("hash0")).toEqual({ key: "key0", scope: "global" });
|
||||
expect(catalog.getKeyOptions(`hash${count - 1}`)).toEqual({
|
||||
key: `key${count - 1}`,
|
||||
scope: "global",
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() removes all entries", () => {
|
||||
const catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
|
||||
catalog.registerKeyOptions("hash1", { key: "key1", scope: "global" });
|
||||
catalog.registerKeyOptions("hash2", { key: "key2", scope: "run" });
|
||||
|
||||
catalog.clear();
|
||||
|
||||
expect(catalog.getKeyOptions("hash1")).toBeUndefined();
|
||||
expect(catalog.getKeyOptions("hash2")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";
|
||||
|
||||
/**
|
||||
* Maps an idempotency-key hash back to the original user-provided key and scope.
|
||||
*
|
||||
* The mapping is held for the lifetime of a single run: the worker clears it at
|
||||
* each run boundary (warm starts reuse the process), so it never accumulates
|
||||
* across runs. Within a run every registered key is retained regardless of how
|
||||
* many are created, so the key/scope metadata is never silently dropped.
|
||||
*/
|
||||
export class InMemoryIdempotencyKeyCatalog implements IdempotencyKeyCatalog {
|
||||
private cache = new Map<string, IdempotencyKeyOptions>();
|
||||
|
||||
registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void {
|
||||
this.cache.set(hash, options);
|
||||
}
|
||||
|
||||
getKeyOptions(hash: string): IdempotencyKeyOptions | undefined {
|
||||
return this.cache.get(hash);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const API_NAME = "idempotency-key-catalog";
|
||||
|
||||
import { getGlobal, registerGlobal } from "../utils/globals.js";
|
||||
import type { IdempotencyKeyCatalog, IdempotencyKeyOptions } from "./catalog.js";
|
||||
import { InMemoryIdempotencyKeyCatalog } from "./inMemoryIdempotencyKeyCatalog.js";
|
||||
|
||||
export class IdempotencyKeyCatalogAPI {
|
||||
private static _instance?: IdempotencyKeyCatalogAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): IdempotencyKeyCatalogAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new IdempotencyKeyCatalogAPI();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public registerKeyOptions(hash: string, options: IdempotencyKeyOptions): void {
|
||||
this.#getCatalog().registerKeyOptions(hash, options);
|
||||
}
|
||||
|
||||
public getKeyOptions(hash: string): IdempotencyKeyOptions | undefined {
|
||||
return this.#getCatalog().getKeyOptions(hash);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.#getCatalog().clear();
|
||||
}
|
||||
|
||||
#getCatalog(): IdempotencyKeyCatalog {
|
||||
let catalog = getGlobal(API_NAME);
|
||||
if (!catalog) {
|
||||
// Auto-initialize on first access
|
||||
catalog = new InMemoryIdempotencyKeyCatalog();
|
||||
registerGlobal(API_NAME, catalog, true);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
getIdempotencyKeyOptions,
|
||||
resetIdempotencyKeyCatalog,
|
||||
} from "./idempotencyKeys.js";
|
||||
|
||||
describe("idempotencyKeys metadata retention", () => {
|
||||
it("retains key/scope options for every key created in a run, even beyond 1000", async () => {
|
||||
const count = 3000;
|
||||
const keys: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = await createIdempotencyKey(`item-${i}`, { scope: "global" });
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
// The very first key created should still resolve its original options.
|
||||
// With a fixed-size LRU catalog (cap 1000), the earliest ~2000 keys are
|
||||
// silently evicted and this returns undefined.
|
||||
const firstOptions = getIdempotencyKeyOptions(keys[0]!);
|
||||
expect(firstOptions).toEqual({ key: "item-0", scope: "global" });
|
||||
|
||||
// Every key should resolve to its own original options.
|
||||
for (let i = 0; i < count; i++) {
|
||||
const options = getIdempotencyKeyOptions(keys[i]!);
|
||||
expect(options, `options missing for key index ${i}`).toEqual({
|
||||
key: `item-${i}`,
|
||||
scope: "global",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("forgets options after the catalog is reset at a run boundary", async () => {
|
||||
const key = await createIdempotencyKey("boundary-key", { scope: "global" });
|
||||
expect(getIdempotencyKeyOptions(key)).toEqual({ key: "boundary-key", scope: "global" });
|
||||
|
||||
resetIdempotencyKeyCatalog();
|
||||
|
||||
expect(getIdempotencyKeyOptions(key)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { apiClientManager } from "./apiClientManager-api.js";
|
||||
import { idempotencyKeyCatalog } from "./idempotency-key-catalog-api.js";
|
||||
import type {
|
||||
IdempotencyKeyOptions,
|
||||
IdempotencyKeyScope,
|
||||
} from "./idempotency-key-catalog/catalog.js";
|
||||
import { taskContext } from "./task-context-api.js";
|
||||
import type { IdempotencyKey } from "./types/idempotencyKeys.js";
|
||||
import { digestSHA256 } from "./utils/crypto.js";
|
||||
import type { ZodFetchOptions } from "./apiClient/core.js";
|
||||
|
||||
// Re-export types from catalog for backwards compatibility
|
||||
export type {
|
||||
IdempotencyKeyScope,
|
||||
IdempotencyKeyOptions,
|
||||
} from "./idempotency-key-catalog/catalog.js";
|
||||
|
||||
/**
|
||||
* Extracts the user-provided key and scope from an idempotency key created with `idempotencyKeys.create()`.
|
||||
*
|
||||
* @param idempotencyKey The idempotency key to extract options from
|
||||
* @returns The original key and scope, or undefined if the key doesn't have attached options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const key = await idempotencyKeys.create("my-key", { scope: "global" });
|
||||
* const options = getIdempotencyKeyOptions(key);
|
||||
* // options = { key: "my-key", scope: "global" }
|
||||
* ```
|
||||
*/
|
||||
export function getIdempotencyKeyOptions(
|
||||
idempotencyKey: IdempotencyKey | string
|
||||
): IdempotencyKeyOptions | undefined {
|
||||
// Look up options from the catalog using the hash string
|
||||
if (typeof idempotencyKey === "string") {
|
||||
return idempotencyKeyCatalog.getKeyOptions(idempotencyKey);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the in-process idempotency key catalog.
|
||||
*
|
||||
* The catalog maps an idempotency-key hash back to its original key and scope so
|
||||
* the SDK can attach that metadata when triggering. The worker calls this at each
|
||||
* run boundary so the mapping does not accumulate across warm-start runs.
|
||||
*/
|
||||
export function resetIdempotencyKeyCatalog(): void {
|
||||
idempotencyKeyCatalog.clear();
|
||||
}
|
||||
|
||||
export function isIdempotencyKey(
|
||||
value: string | string[] | IdempotencyKey
|
||||
): value is IdempotencyKey {
|
||||
// Cannot check the brand at runtime because it doesn't exist (it's a TypeScript-only construct)
|
||||
// Check for primitive strings only (we no longer use String objects)
|
||||
if (typeof value === "string") {
|
||||
return value.length === 64;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function flattenIdempotencyKey(
|
||||
idempotencyKey?:
|
||||
| IdempotencyKey
|
||||
| string
|
||||
| string[]
|
||||
| (undefined | IdempotencyKey | string | string[])[]
|
||||
): IdempotencyKey | string | string[] | undefined {
|
||||
if (!idempotencyKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(idempotencyKey)) {
|
||||
//if any items are undefined, then return undefined for the entire key
|
||||
if (idempotencyKey.some((i) => i === undefined)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return idempotencyKey.flatMap((key) => {
|
||||
const k = flattenIdempotencyKey(key);
|
||||
if (!k) return [];
|
||||
return [k];
|
||||
}) as string[];
|
||||
}
|
||||
|
||||
return idempotencyKey;
|
||||
}
|
||||
|
||||
export async function makeIdempotencyKey(
|
||||
idempotencyKey?: IdempotencyKey | string | string[]
|
||||
): Promise<IdempotencyKey | undefined> {
|
||||
if (!idempotencyKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isIdempotencyKey(idempotencyKey)) {
|
||||
return idempotencyKey;
|
||||
}
|
||||
|
||||
return await createIdempotencyKey(idempotencyKey, {
|
||||
scope: "run",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deterministic idempotency key based on the provided key material.
|
||||
*
|
||||
* If running inside a task, the task run ID is automatically included in the key material, giving you a unique key per task run.
|
||||
* This ensures that a given child task is only triggered once per task run, even if the parent task is retried.
|
||||
*
|
||||
* @param {string | string[]} key The key material to create the idempotency key from.
|
||||
* @param {object} [options] Additional options.
|
||||
* @param {"run" | "attempt" | "global"} [options.scope="run"] The scope of the idempotency key.
|
||||
*
|
||||
* @returns {Promise<IdempotencyKey>} The idempotency key as a branded string.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
*
|
||||
* export const myTask = task({
|
||||
* id: "my-task",
|
||||
* run: async (payload: any) => {
|
||||
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
*
|
||||
* // Use the idempotency key when triggering child tasks
|
||||
* await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* You can also use the `scope` parameter to create a key that is unique per task run, task run attempts (retries of the same run), or globally:
|
||||
*
|
||||
* ```typescript
|
||||
* await idempotencyKeys.create("my-task-key", { scope: "attempt" }); // Creates a key that is unique per task run attempt
|
||||
* await idempotencyKeys.create("my-task-key", { scope: "global" }); // Skips including the task run ID
|
||||
* ```
|
||||
*/
|
||||
export async function createIdempotencyKey(
|
||||
key: string | string[],
|
||||
options?: { scope?: IdempotencyKeyScope }
|
||||
): Promise<IdempotencyKey> {
|
||||
const scope = options?.scope ?? "run";
|
||||
const keyArray = Array.isArray(key) ? key : [key];
|
||||
const userKey = keyArray.join("-");
|
||||
|
||||
const idempotencyKey = await generateIdempotencyKey(keyArray.concat(injectScope(scope)));
|
||||
|
||||
// Register the original key and scope in the catalog for later extraction
|
||||
idempotencyKeyCatalog.registerKeyOptions(idempotencyKey, { key: userKey, scope });
|
||||
|
||||
// Return primitive string cast as IdempotencyKey
|
||||
return idempotencyKey as IdempotencyKey;
|
||||
}
|
||||
|
||||
function injectScope(scope: IdempotencyKeyScope): string[] {
|
||||
switch (scope) {
|
||||
case "run": {
|
||||
if (taskContext?.ctx) {
|
||||
return [taskContext.ctx.run.id];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "attempt": {
|
||||
if (taskContext?.ctx) {
|
||||
return [taskContext.ctx.run.id, taskContext.ctx.attempt.number.toString()];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function generateIdempotencyKey(keyMaterial: string[]) {
|
||||
return await digestSHA256(keyMaterial.join("-"));
|
||||
}
|
||||
|
||||
type AttemptKeyMaterial = {
|
||||
run: {
|
||||
id: string;
|
||||
};
|
||||
attempt: {
|
||||
number: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** Creates a unique key for each attempt. */
|
||||
export function attemptKey(ctx: AttemptKeyMaterial): string {
|
||||
return `${ctx.run.id}-${ctx.attempt.number}`;
|
||||
}
|
||||
|
||||
export type ResetIdempotencyKeyOptions = {
|
||||
scope?: IdempotencyKeyScope;
|
||||
/** Required if scope is "run" or "attempt" to reconstruct the hash */
|
||||
parentRunId?: string;
|
||||
/** Required if scope is "attempt" to reconstruct the hash */
|
||||
attemptNumber?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets an idempotency key, effectively deleting it from the associated task.
|
||||
*
|
||||
* @param taskIdentifier The task identifier (e.g., "my-task")
|
||||
* @param idempotencyKey The idempotency key to reset. Can be:
|
||||
* - An `IdempotencyKey` created with `idempotencyKeys.create()` (options are extracted automatically)
|
||||
* - A string or string array (requires `options.scope` and potentially `options.parentRunId`)
|
||||
* @param options Options for reconstructing the hash if needed
|
||||
* @param requestOptions Optional fetch options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using a key created with idempotencyKeys.create() - options are extracted automatically
|
||||
* const key = await idempotencyKeys.create("my-key", { scope: "global" });
|
||||
* await idempotencyKeys.reset("my-task", key);
|
||||
*
|
||||
* // Using a raw string with global scope
|
||||
* await idempotencyKeys.reset("my-task", "my-key", { scope: "global" });
|
||||
*
|
||||
* // Using a raw string with run scope (requires parentRunId)
|
||||
* await idempotencyKeys.reset("my-task", "my-key", {
|
||||
* scope: "run",
|
||||
* parentRunId: "run_abc123"
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function resetIdempotencyKey(
|
||||
taskIdentifier: string,
|
||||
idempotencyKey: IdempotencyKey | string | string[],
|
||||
options?: ResetIdempotencyKeyOptions,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): Promise<{ id: string }> {
|
||||
const client = apiClientManager.clientOrThrow();
|
||||
|
||||
// If the key is already a 64-char hash, use it directly
|
||||
if (typeof idempotencyKey === "string" && idempotencyKey.length === 64) {
|
||||
return client.resetIdempotencyKey(taskIdentifier, idempotencyKey, requestOptions);
|
||||
}
|
||||
|
||||
// Try to extract options from an IdempotencyKey created with idempotencyKeys.create()
|
||||
const attachedOptions =
|
||||
typeof idempotencyKey === "string" ? getIdempotencyKeyOptions(idempotencyKey) : undefined;
|
||||
|
||||
const scope = attachedOptions?.scope ?? options?.scope ?? "run";
|
||||
const keyArray = Array.isArray(idempotencyKey)
|
||||
? idempotencyKey
|
||||
: [attachedOptions?.key ?? String(idempotencyKey)];
|
||||
|
||||
// Build scope suffix based on scope type
|
||||
let scopeSuffix: string[] = [];
|
||||
switch (scope) {
|
||||
case "run": {
|
||||
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
|
||||
if (!parentRunId) {
|
||||
throw new Error(
|
||||
"resetIdempotencyKey: parentRunId is required for 'run' scope when called outside a task context"
|
||||
);
|
||||
}
|
||||
scopeSuffix = [parentRunId];
|
||||
break;
|
||||
}
|
||||
case "attempt": {
|
||||
const parentRunId = options?.parentRunId ?? taskContext?.ctx?.run.id;
|
||||
const attemptNumber = options?.attemptNumber ?? taskContext?.ctx?.attempt.number;
|
||||
if (!parentRunId || attemptNumber === undefined) {
|
||||
throw new Error(
|
||||
"resetIdempotencyKey: parentRunId and attemptNumber are required for 'attempt' scope when called outside a task context"
|
||||
);
|
||||
}
|
||||
scopeSuffix = [parentRunId, attemptNumber.toString()];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the hash using the same algorithm as createIdempotencyKey
|
||||
const hash = await generateIdempotencyKey(keyArray.concat(scopeSuffix));
|
||||
|
||||
return client.resetIdempotencyKey(taskIdentifier, hash, requestOptions);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Use vendored superjson bundle to avoid ESM/CJS compatibility issues
|
||||
// See: https://github.com/triggerdotdev/trigger.dev/issues/2937
|
||||
// @ts-ignore
|
||||
const superjson = require("../vendor/superjson.cjs");
|
||||
|
||||
// @ts-ignore
|
||||
superjson.default.registerCustom<Buffer, number[]>(
|
||||
{
|
||||
isApplicable: (v: unknown): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v),
|
||||
serialize: (v: Buffer) => [...v],
|
||||
deserialize: (v: number[]) => Buffer.from(v),
|
||||
},
|
||||
"buffer"
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
module.exports.default = superjson.default;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Use vendored superjson bundle to avoid ESM/CJS compatibility issues
|
||||
// See: https://github.com/triggerdotdev/trigger.dev/issues/2937
|
||||
// @ts-ignore
|
||||
import superjson from "../vendor/superjson.mjs";
|
||||
|
||||
superjson.registerCustom<Buffer, number[]>(
|
||||
{
|
||||
isApplicable: (v: unknown): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v),
|
||||
serialize: (v: Buffer) => [...v],
|
||||
deserialize: (v: number[]) => Buffer.from(v),
|
||||
},
|
||||
"buffer"
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
export default superjson;
|
||||
@@ -0,0 +1,93 @@
|
||||
export * from "./apiClient/index.js";
|
||||
export * from "./apiClient/types.js";
|
||||
export * from "./apiClient/pagination.js";
|
||||
export type { ApiPromise, OffsetLimitPagePromise, CursorPagePromise } from "./apiClient/core.js";
|
||||
export * from "./apiClient/errors.js";
|
||||
export * from "./clock-api.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./limits.js";
|
||||
export * from "./logger-api.js";
|
||||
export * from "./runtime-api.js";
|
||||
export * from "./task-context-api.js";
|
||||
export * from "./trace-context-api.js";
|
||||
export * from "./apiClientManager-api.js";
|
||||
export * from "./sdkScope-api.js";
|
||||
export * from "./usage-api.js";
|
||||
export * from "./run-metadata-api.js";
|
||||
export * from "./wait-until-api.js";
|
||||
export * from "./timeout-api.js";
|
||||
export * from "./run-timeline-metrics-api.js";
|
||||
export * from "./lifecycle-hooks-api.js";
|
||||
export * from "./locals-api.js";
|
||||
export * from "./heartbeats-api.js";
|
||||
export * from "./realtime-streams-api.js";
|
||||
export * from "./input-streams-api.js";
|
||||
export * from "./session-streams-api.js";
|
||||
export * from "./waitpoints/index.js";
|
||||
export * from "./schemas/index.js";
|
||||
export { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
|
||||
export * from "./resource-catalog-api.js";
|
||||
export * from "./types/index.js";
|
||||
export { links } from "./links.js";
|
||||
export * from "./jwt.js";
|
||||
export * from "./idempotencyKeys.js";
|
||||
export * from "./streams/asyncIterableStream.js";
|
||||
export * from "./utils/getEnv.js";
|
||||
export * from "./tryCatch.js";
|
||||
export {
|
||||
formatDuration,
|
||||
formatDurationInDays,
|
||||
formatDurationMilliseconds,
|
||||
formatDurationNanoseconds,
|
||||
millisecondsToNanoseconds,
|
||||
nanosecondsToMilliseconds,
|
||||
} from "./utils/durations.js";
|
||||
|
||||
export type { LogLevel } from "./logger/taskLogger.js";
|
||||
|
||||
export { eventFilterMatches } from "../eventFilterMatches.js";
|
||||
export {
|
||||
flattenAttributes,
|
||||
primitiveValueOrflattenedAttributes,
|
||||
unflattenAttributes,
|
||||
NULL_SENTINEL,
|
||||
} from "./utils/flattenAttributes.js";
|
||||
export { omit } from "./utils/omit.js";
|
||||
export { generateFriendlyId, fromFriendlyId } from "./isomorphic/friendlyId.js";
|
||||
export {
|
||||
calculateNextRetryDelay,
|
||||
calculateResetAt,
|
||||
defaultFetchRetryOptions,
|
||||
defaultRetryOptions,
|
||||
} from "./utils/retries.js";
|
||||
export { accessoryAttributes } from "./utils/styleAttributes.js";
|
||||
|
||||
export {
|
||||
conditionallyExportPacket,
|
||||
conditionallyImportPacket,
|
||||
createPacketAttributes,
|
||||
createPacketAttributesAsJson,
|
||||
packetRequiresOffloading,
|
||||
parsePacket,
|
||||
prettyPrintPacket,
|
||||
stringifyIO,
|
||||
type IOPacket,
|
||||
} from "./utils/ioSerialization.js";
|
||||
|
||||
export * from "./utils/imageRef.js";
|
||||
export * from "./utils/interval.js";
|
||||
|
||||
export * from "./config.js";
|
||||
export {
|
||||
getSchemaParseFn,
|
||||
type AnySchemaParseFn,
|
||||
type SchemaParseFn,
|
||||
type inferSchemaOut,
|
||||
isSchemaZodEsque,
|
||||
isSchemaValibotEsque,
|
||||
isSchemaArkTypeEsque,
|
||||
} from "./types/schemas.js";
|
||||
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export { VERSION as CORE_VERSION };
|
||||
@@ -0,0 +1,7 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { InputStreamsAPI } from "./inputStreams/index.js";
|
||||
|
||||
export const inputStreams = InputStreamsAPI.getInstance();
|
||||
|
||||
export * from "./inputStreams/types.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getGlobal, registerGlobal } from "../utils/globals.js";
|
||||
import { NoopInputStreamManager } from "./noopManager.js";
|
||||
import type { InputStreamManager, InputStreamOncePromise } from "./types.js";
|
||||
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
|
||||
|
||||
const API_NAME = "input-streams";
|
||||
|
||||
const NOOP_MANAGER = new NoopInputStreamManager();
|
||||
|
||||
export class InputStreamsAPI implements InputStreamManager {
|
||||
private static _instance?: InputStreamsAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): InputStreamsAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new InputStreamsAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
setGlobalManager(manager: InputStreamManager): boolean {
|
||||
return registerGlobal(API_NAME, manager);
|
||||
}
|
||||
|
||||
#getManager(): InputStreamManager {
|
||||
return getGlobal(API_NAME) ?? NOOP_MANAGER;
|
||||
}
|
||||
|
||||
public setRunId(runId: string, streamsVersion?: string): void {
|
||||
this.#getManager().setRunId(runId, streamsVersion);
|
||||
}
|
||||
|
||||
public on(
|
||||
streamId: string,
|
||||
handler: (data: unknown) => void | Promise<void>
|
||||
): { off: () => void } {
|
||||
return this.#getManager().on(streamId, handler);
|
||||
}
|
||||
|
||||
public once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise<unknown> {
|
||||
return this.#getManager().once(streamId, options);
|
||||
}
|
||||
|
||||
public peek(streamId: string): unknown | undefined {
|
||||
return this.#getManager().peek(streamId);
|
||||
}
|
||||
|
||||
public lastSeqNum(streamId: string): number | undefined {
|
||||
return this.#getManager().lastSeqNum(streamId);
|
||||
}
|
||||
|
||||
public setLastSeqNum(streamId: string, seqNum: number): void {
|
||||
this.#getManager().setLastSeqNum(streamId, seqNum);
|
||||
}
|
||||
|
||||
public shiftBuffer(streamId: string): boolean {
|
||||
return this.#getManager().shiftBuffer(streamId);
|
||||
}
|
||||
|
||||
public disconnectStream(streamId: string): void {
|
||||
this.#getManager().disconnectStream(streamId);
|
||||
}
|
||||
|
||||
public clearHandlers(): void {
|
||||
this.#getManager().clearHandlers();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.#getManager().reset();
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.#getManager().disconnect();
|
||||
}
|
||||
|
||||
public connectTail(runId: string, fromSeq?: number): void {
|
||||
this.#getManager().connectTail(runId, fromSeq);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { ApiClient } from "../apiClient/index.js";
|
||||
import type { InputStreamManager, InputStreamOnceResult } from "./types.js";
|
||||
import { InputStreamOncePromise, InputStreamTimeoutError } from "./types.js";
|
||||
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
|
||||
import { computeReconnectDelayMs } from "../utils/reconnectBackoff.js";
|
||||
|
||||
type InputStreamHandler = (data: unknown) => void | Promise<void>;
|
||||
|
||||
type OnceWaiter = {
|
||||
resolve: (result: InputStreamOnceResult<unknown>) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle?: ReturnType<typeof setTimeout>;
|
||||
// The abort signal and its handler are tracked on the waiter so any
|
||||
// resolution path (dispatch / timeout / explicit removal) can detach
|
||||
// the listener. Without this, a long-lived `AbortSignal` reused across
|
||||
// many `once()` calls accumulates listeners — `{ once: true }` only
|
||||
// self-clears if the signal actually aborts.
|
||||
signal?: AbortSignal;
|
||||
abortHandler?: () => void;
|
||||
};
|
||||
|
||||
type TailState = {
|
||||
abortController: AbortController;
|
||||
promise: Promise<void>;
|
||||
};
|
||||
|
||||
export class StandardInputStreamManager implements InputStreamManager {
|
||||
private handlers = new Map<string, Set<InputStreamHandler>>();
|
||||
private onceWaiters = new Map<string, OnceWaiter[]>();
|
||||
private buffer = new Map<string, unknown[]>();
|
||||
private tails = new Map<string, TailState>();
|
||||
private seqNums = new Map<string, number>();
|
||||
private currentRunId: string | null = null;
|
||||
private streamsVersion: string | undefined;
|
||||
// Reconnect attempt counter per streamId. Drives the exponential
|
||||
// backoff applied by `#ensureStreamTailConnected`'s `.finally` so a
|
||||
// persistent backend failure (auth rejection, 5xx, DNS, etc.) doesn't
|
||||
// reconnect in a tight loop. Reset to 0 by `#dispatch` whenever a
|
||||
// record flows through.
|
||||
private reconnectAttempts = new Map<string, number>();
|
||||
// Stream IDs that were explicitly torn down by `disconnectStream`. The
|
||||
// tail's `.finally` reconnect path consults this set so a deliberate
|
||||
// teardown isn't immediately undone by the auto-reconnect when
|
||||
// handlers or once-waiters are still registered. Cleared on the next
|
||||
// explicit `on()` / `once()` (those are the only legitimate reasons to
|
||||
// bring the tail back up).
|
||||
private explicitlyDisconnected = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private apiClient: ApiClient,
|
||||
private baseUrl: string,
|
||||
private debug: boolean = false
|
||||
) {}
|
||||
|
||||
lastSeqNum(streamId: string): number | undefined {
|
||||
return this.seqNums.get(streamId);
|
||||
}
|
||||
|
||||
setLastSeqNum(streamId: string, seqNum: number): void {
|
||||
const current = this.seqNums.get(streamId);
|
||||
// Only advance forward, never backward
|
||||
if (current === undefined || seqNum > current) {
|
||||
this.seqNums.set(streamId, seqNum);
|
||||
}
|
||||
}
|
||||
|
||||
shiftBuffer(streamId: string): boolean {
|
||||
const buffered = this.buffer.get(streamId);
|
||||
if (buffered && buffered.length > 0) {
|
||||
buffered.shift();
|
||||
if (buffered.length === 0) {
|
||||
this.buffer.delete(streamId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setRunId(runId: string, streamsVersion?: string): void {
|
||||
this.currentRunId = runId;
|
||||
this.streamsVersion = streamsVersion;
|
||||
}
|
||||
|
||||
on(streamId: string, handler: InputStreamHandler): { off: () => void } {
|
||||
this.#requireV2Streams();
|
||||
|
||||
// A fresh attach is a legitimate reason to bring the tail back up;
|
||||
// clear any prior explicit-disconnect flag.
|
||||
this.explicitlyDisconnected.delete(streamId);
|
||||
|
||||
let handlerSet = this.handlers.get(streamId);
|
||||
if (!handlerSet) {
|
||||
handlerSet = new Set();
|
||||
this.handlers.set(streamId, handlerSet);
|
||||
}
|
||||
handlerSet.add(handler);
|
||||
|
||||
// Lazily connect a tail for this stream
|
||||
this.#ensureStreamTailConnected(streamId);
|
||||
|
||||
// Flush any buffered data for this stream
|
||||
const buffered = this.buffer.get(streamId);
|
||||
if (buffered && buffered.length > 0) {
|
||||
for (const data of buffered) {
|
||||
this.#invokeHandler(handler, data);
|
||||
}
|
||||
this.buffer.delete(streamId);
|
||||
}
|
||||
|
||||
return {
|
||||
off: () => {
|
||||
handlerSet?.delete(handler);
|
||||
if (handlerSet?.size === 0) {
|
||||
this.handlers.delete(streamId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise<unknown> {
|
||||
this.#requireV2Streams();
|
||||
|
||||
// A fresh waiter is a legitimate reason to bring the tail back up;
|
||||
// clear any prior explicit-disconnect flag.
|
||||
this.explicitlyDisconnected.delete(streamId);
|
||||
|
||||
// Lazily connect a tail for this stream
|
||||
this.#ensureStreamTailConnected(streamId);
|
||||
|
||||
// Check buffer first
|
||||
const buffered = this.buffer.get(streamId);
|
||||
if (buffered && buffered.length > 0) {
|
||||
const data = buffered.shift()!;
|
||||
if (buffered.length === 0) {
|
||||
this.buffer.delete(streamId);
|
||||
}
|
||||
return new InputStreamOncePromise((resolve) => {
|
||||
resolve({ ok: true, output: data });
|
||||
});
|
||||
}
|
||||
|
||||
return new InputStreamOncePromise<unknown>((resolve, reject) => {
|
||||
const waiter: OnceWaiter = { resolve, reject };
|
||||
|
||||
// Handle abort signal
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
const abortHandler = () => {
|
||||
if (waiter.timeoutHandle) {
|
||||
clearTimeout(waiter.timeoutHandle);
|
||||
}
|
||||
this.#removeOnceWaiter(streamId, waiter);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
waiter.signal = options.signal;
|
||||
waiter.abortHandler = abortHandler;
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Handle timeout — resolve with error result instead of rejecting
|
||||
if (options?.timeoutMs) {
|
||||
waiter.timeoutHandle = setTimeout(() => {
|
||||
this.#removeOnceWaiter(streamId, waiter);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: new InputStreamTimeoutError(streamId, options.timeoutMs!),
|
||||
});
|
||||
}, options.timeoutMs);
|
||||
}
|
||||
|
||||
let waiters = this.onceWaiters.get(streamId);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.onceWaiters.set(streamId, waiters);
|
||||
}
|
||||
waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
peek(streamId: string): unknown | undefined {
|
||||
const buffered = this.buffer.get(streamId);
|
||||
if (buffered && buffered.length > 0) {
|
||||
return buffered[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clearHandlers(): void {
|
||||
this.handlers.clear();
|
||||
|
||||
// Abort tails that no longer have any once waiters either
|
||||
for (const [streamId, tail] of this.tails) {
|
||||
const hasWaiters =
|
||||
this.onceWaiters.has(streamId) && this.onceWaiters.get(streamId)!.length > 0;
|
||||
if (!hasWaiters) {
|
||||
tail.abortController.abort();
|
||||
this.tails.delete(streamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectStream(streamId: string): void {
|
||||
// Mark as explicitly disconnected BEFORE we abort, so the tail's
|
||||
// `.finally` reconnect path sees the flag when it runs (which can be
|
||||
// synchronous in the AbortError catch). Without this, an in-flight
|
||||
// `.on(...)` or pending `.once()` would immediately resurrect the
|
||||
// tail and negate the disconnect — defeating the
|
||||
// "drop-the-duplicate before .wait() suspends" contract. Cleared on
|
||||
// the next explicit `on()` / `once()`.
|
||||
this.explicitlyDisconnected.add(streamId);
|
||||
const tail = this.tails.get(streamId);
|
||||
if (tail) {
|
||||
tail.abortController.abort();
|
||||
this.tails.delete(streamId);
|
||||
}
|
||||
this.buffer.delete(streamId);
|
||||
// Reset the backoff counter so a future re-attach starts fresh —
|
||||
// an explicit disconnect is a deliberate teardown, not evidence of
|
||||
// a broken backend.
|
||||
this.reconnectAttempts.delete(streamId);
|
||||
}
|
||||
|
||||
connectTail(runId: string, _fromSeq?: number): void {
|
||||
// No-op: tails are now created per-stream lazily
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down all active tails. Does NOT clear handlers or `onceWaiters`,
|
||||
* so any registered listener will trigger an auto-reconnect (with
|
||||
* backoff) the moment it sees no live tail — by design, so a transient
|
||||
* network blip recovers without the caller re-subscribing. Use
|
||||
* `reset()` if you want a full clean state with no resurrection, or
|
||||
* `disconnectStream(streamId)` for a single stream that should stay
|
||||
* down until a fresh `on()` / `once()` attaches.
|
||||
*/
|
||||
disconnect(): void {
|
||||
for (const [, tail] of this.tails) {
|
||||
tail.abortController.abort();
|
||||
}
|
||||
this.tails.clear();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.disconnect();
|
||||
this.currentRunId = null;
|
||||
this.streamsVersion = undefined;
|
||||
this.seqNums.clear();
|
||||
this.handlers.clear();
|
||||
this.reconnectAttempts.clear();
|
||||
this.explicitlyDisconnected.clear();
|
||||
|
||||
// Reject all pending once waiters
|
||||
for (const [, waiters] of this.onceWaiters) {
|
||||
for (const waiter of waiters) {
|
||||
if (waiter.timeoutHandle) {
|
||||
clearTimeout(waiter.timeoutHandle);
|
||||
}
|
||||
if (waiter.signal && waiter.abortHandler) {
|
||||
waiter.signal.removeEventListener("abort", waiter.abortHandler);
|
||||
}
|
||||
waiter.reject(new Error("Input stream manager reset"));
|
||||
}
|
||||
}
|
||||
this.onceWaiters.clear();
|
||||
this.buffer.clear();
|
||||
}
|
||||
|
||||
#requireV2Streams(): void {
|
||||
if (this.currentRunId && this.streamsVersion !== "v2") {
|
||||
throw new Error(
|
||||
"Input streams require v2 realtime streams. Enable them with: { future: { v2RealtimeStreams: true } }"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#ensureStreamTailConnected(streamId: string): void {
|
||||
if (!this.tails.has(streamId) && this.currentRunId) {
|
||||
const abortController = new AbortController();
|
||||
const promise = this.#runTail(this.currentRunId, streamId, abortController.signal)
|
||||
.catch((error) => {
|
||||
if (this.debug) {
|
||||
console.error(`[InputStreamManager] Tail error for "${streamId}":`, error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.tails.delete(streamId);
|
||||
|
||||
// If the tail was torn down explicitly via `disconnectStream`,
|
||||
// don't auto-reconnect — that's the whole point of the
|
||||
// disconnect call. The next `on()` / `once()` clears the flag.
|
||||
if (this.explicitlyDisconnected.has(streamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-reconnect with exponential backoff if there are still
|
||||
// active handlers or waiters. Without backoff a persistent
|
||||
// failure (auth rejected, 5xx, DNS) would reconnect in a tight
|
||||
// loop because `#runTail`'s error path only logs. `#dispatch`
|
||||
// resets the counter on every successful record.
|
||||
const hasHandlers = this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0;
|
||||
const hasWaiters =
|
||||
this.onceWaiters.has(streamId) && this.onceWaiters.get(streamId)!.length > 0;
|
||||
if (hasHandlers || hasWaiters) {
|
||||
const attempt = this.reconnectAttempts.get(streamId) ?? 0;
|
||||
this.reconnectAttempts.set(streamId, attempt + 1);
|
||||
const delayMs = computeReconnectDelayMs(attempt);
|
||||
setTimeout(() => {
|
||||
if (this.explicitlyDisconnected.has(streamId)) return;
|
||||
if (this.tails.has(streamId)) return;
|
||||
const stillHasHandlers =
|
||||
this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0;
|
||||
const stillHasWaiters =
|
||||
this.onceWaiters.has(streamId) && this.onceWaiters.get(streamId)!.length > 0;
|
||||
if (!stillHasHandlers && !stillHasWaiters) return;
|
||||
this.#ensureStreamTailConnected(streamId);
|
||||
}, delayMs);
|
||||
}
|
||||
});
|
||||
this.tails.set(streamId, { abortController, promise });
|
||||
}
|
||||
}
|
||||
|
||||
async #runTail(runId: string, streamId: string, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const lastSeq = this.seqNums.get(streamId);
|
||||
const stream = await this.apiClient.fetchStream<unknown>(runId, `input/${streamId}`, {
|
||||
signal,
|
||||
baseUrl: this.baseUrl,
|
||||
// Max allowed by the SSE endpoint is 600s; the tail will reconnect on close
|
||||
timeoutInSeconds: 600,
|
||||
// Resume from last seen sequence number to avoid replaying history on reconnect
|
||||
lastEventId: lastSeq !== undefined ? String(lastSeq) : undefined,
|
||||
onPart: (part) => {
|
||||
const seqNum = parseInt(part.id, 10);
|
||||
if (Number.isFinite(seqNum)) {
|
||||
this.seqNums.set(streamId, seqNum);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (this.debug) {
|
||||
console.log(`[InputStreamManager] Tail stream completed for "${streamId}"`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (this.debug) {
|
||||
console.error(`[InputStreamManager] Tail stream error for "${streamId}":`, error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for await (const record of stream) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
// S2 SSE returns record bodies as JSON strings; parse if needed
|
||||
let data: unknown;
|
||||
if (typeof record === "string") {
|
||||
try {
|
||||
data = JSON.parse(record);
|
||||
} catch {
|
||||
data = record;
|
||||
}
|
||||
} else {
|
||||
data = record;
|
||||
}
|
||||
|
||||
this.#dispatch(streamId, data);
|
||||
}
|
||||
} catch (error) {
|
||||
// AbortError is expected when disconnecting
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#dispatch(streamId: string, data: unknown): void {
|
||||
// Any record flowing through = healthy connection; reset the backoff
|
||||
// counter so the next disconnect starts fresh.
|
||||
this.reconnectAttempts.delete(streamId);
|
||||
|
||||
// First try to resolve a once waiter
|
||||
const waiters = this.onceWaiters.get(streamId);
|
||||
if (waiters && waiters.length > 0) {
|
||||
const waiter = waiters.shift()!;
|
||||
if (waiters.length === 0) {
|
||||
this.onceWaiters.delete(streamId);
|
||||
}
|
||||
if (waiter.timeoutHandle) {
|
||||
clearTimeout(waiter.timeoutHandle);
|
||||
}
|
||||
if (waiter.signal && waiter.abortHandler) {
|
||||
waiter.signal.removeEventListener("abort", waiter.abortHandler);
|
||||
}
|
||||
waiter.resolve({ ok: true, output: data });
|
||||
// Also invoke persistent handlers
|
||||
this.#invokeHandlers(streamId, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invoke persistent handlers
|
||||
const handlers = this.handlers.get(streamId);
|
||||
if (handlers && handlers.size > 0) {
|
||||
this.#invokeHandlers(streamId, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// No handlers, buffer the data
|
||||
let buffered = this.buffer.get(streamId);
|
||||
if (!buffered) {
|
||||
buffered = [];
|
||||
this.buffer.set(streamId, buffered);
|
||||
}
|
||||
buffered.push(data);
|
||||
}
|
||||
|
||||
#invokeHandlers(streamId: string, data: unknown): void {
|
||||
const handlers = this.handlers.get(streamId);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) {
|
||||
this.#invokeHandler(handler, data);
|
||||
}
|
||||
}
|
||||
|
||||
#invokeHandler(handler: InputStreamHandler, data: unknown): void {
|
||||
try {
|
||||
const result = handler(data);
|
||||
// If the handler returns a promise, catch errors silently
|
||||
if (result && typeof result === "object" && "catch" in result) {
|
||||
(result as Promise<void>).catch((error) => {
|
||||
if (this.debug) {
|
||||
console.error("[InputStreamManager] Handler error:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.debug) {
|
||||
console.error("[InputStreamManager] Handler error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#removeOnceWaiter(streamId: string, waiter: OnceWaiter): void {
|
||||
// Centralized cleanup — both timeout and explicit abort paths funnel
|
||||
// through here, so detach the abort listener once instead of at every
|
||||
// callsite. The dispatch path doesn't go through this method (the
|
||||
// waiter is shifted off inline), so it detaches the listener there.
|
||||
if (waiter.signal && waiter.abortHandler) {
|
||||
waiter.signal.removeEventListener("abort", waiter.abortHandler);
|
||||
}
|
||||
const waiters = this.onceWaiters.get(streamId);
|
||||
if (!waiters) return;
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index !== -1) {
|
||||
waiters.splice(index, 1);
|
||||
}
|
||||
if (waiters.length === 0) {
|
||||
this.onceWaiters.delete(streamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { InputStreamManager } from "./types.js";
|
||||
import { InputStreamOncePromise } from "./types.js";
|
||||
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
|
||||
|
||||
export class NoopInputStreamManager implements InputStreamManager {
|
||||
setRunId(_runId: string, _streamsVersion?: string): void {}
|
||||
|
||||
on(_streamId: string, _handler: (data: unknown) => void | Promise<void>): { off: () => void } {
|
||||
return { off: () => {} };
|
||||
}
|
||||
|
||||
once(_streamId: string, _options?: InputStreamOnceOptions): InputStreamOncePromise<unknown> {
|
||||
return new InputStreamOncePromise(() => {
|
||||
// Never resolves in noop mode
|
||||
});
|
||||
}
|
||||
|
||||
peek(_streamId: string): unknown | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
lastSeqNum(_streamId: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setLastSeqNum(_streamId: string, _seqNum: number): void {}
|
||||
|
||||
shiftBuffer(_streamId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
disconnectStream(_streamId: string): void {}
|
||||
|
||||
clearHandlers(): void {}
|
||||
reset(): void {}
|
||||
disconnect(): void {}
|
||||
connectTail(_runId: string, _fromSeq?: number): void {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { InputStreamOnceOptions } from "../realtimeStreams/types.js";
|
||||
|
||||
export class InputStreamTimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly streamId: string,
|
||||
public readonly timeoutMs: number
|
||||
) {
|
||||
super(`Timeout waiting for input stream "${streamId}" after ${timeoutMs}ms`);
|
||||
this.name = "InputStreamTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export type InputStreamOnceResult<TData> =
|
||||
| { ok: true; output: TData }
|
||||
| { ok: false; error: InputStreamTimeoutError };
|
||||
|
||||
export class InputStreamOncePromise<TData> extends Promise<InputStreamOnceResult<TData>> {
|
||||
constructor(
|
||||
executor: (
|
||||
resolve: (
|
||||
value: InputStreamOnceResult<TData> | PromiseLike<InputStreamOnceResult<TData>>
|
||||
) => void,
|
||||
reject: (reason?: any) => void
|
||||
) => void
|
||||
) {
|
||||
super(executor);
|
||||
}
|
||||
|
||||
unwrap(): Promise<TData> {
|
||||
return this.then((result) => {
|
||||
if (result.ok) {
|
||||
return result.output;
|
||||
} else {
|
||||
throw result.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface InputStreamManager {
|
||||
/**
|
||||
* Set the current run ID and streams version. The tail connection will be
|
||||
* established lazily when `on()` or `once()` is first called, but only
|
||||
* for v2 (S2-backed) realtime streams.
|
||||
*/
|
||||
setRunId(runId: string, streamsVersion?: string): void;
|
||||
|
||||
/**
|
||||
* Register a handler that fires every time data arrives on the given input stream.
|
||||
* Handlers are automatically cleaned up when the task run completes.
|
||||
* Returns `{ off }` for early unsubscription if needed.
|
||||
*/
|
||||
on(streamId: string, handler: (data: unknown) => void | Promise<void>): { off: () => void };
|
||||
|
||||
/**
|
||||
* Wait for the next piece of data on the given input stream.
|
||||
* Returns a result object `{ ok, output }` or `{ ok, error }`.
|
||||
* Chain `.unwrap()` to get the data directly or throw on timeout.
|
||||
*/
|
||||
once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise<unknown>;
|
||||
|
||||
/**
|
||||
* Non-blocking peek at the most recent data on the given input stream.
|
||||
*/
|
||||
peek(streamId: string): unknown | undefined;
|
||||
|
||||
/**
|
||||
* The last S2 sequence number seen for the given input stream.
|
||||
* Used by `.wait()` to tell the server where to check for existing data.
|
||||
*/
|
||||
lastSeqNum(streamId: string): number | undefined;
|
||||
|
||||
/**
|
||||
* Advance the last-seen S2 sequence number for the given input stream.
|
||||
* Used after `.wait()` resumes to prevent the SSE tail from replaying
|
||||
* the record that was consumed via the waitpoint path.
|
||||
*/
|
||||
setLastSeqNum(streamId: string, seqNum: number): void;
|
||||
|
||||
/**
|
||||
* Remove and discard the first buffered item for the given input stream.
|
||||
* Used after `.wait()` resumes to remove the duplicate that the SSE tail
|
||||
* buffered while the waitpoint was being completed via a separate path.
|
||||
* Returns true if an item was removed, false if the buffer was empty.
|
||||
*/
|
||||
shiftBuffer(streamId: string): boolean;
|
||||
|
||||
/**
|
||||
* Disconnect the SSE tail and clear the buffer for a specific input stream.
|
||||
* Used before suspending via `.wait()` so the tail doesn't buffer duplicates
|
||||
* of data that will be delivered through the waitpoint path.
|
||||
*/
|
||||
disconnectStream(streamId: string): void;
|
||||
|
||||
/**
|
||||
* Clear all persistent `.on()` handlers and abort tails that have no remaining once waiters.
|
||||
* Called automatically when a task run completes.
|
||||
*/
|
||||
clearHandlers(): void;
|
||||
|
||||
/**
|
||||
* Reset state between task executions.
|
||||
*/
|
||||
reset(): void;
|
||||
|
||||
/**
|
||||
* Disconnect any active tails / connections.
|
||||
*/
|
||||
disconnect(): void;
|
||||
|
||||
/**
|
||||
* Connect a tail to receive input stream records for the given run.
|
||||
*/
|
||||
connectTail(runId: string, fromSeq?: number): void;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const CURRENT_DEPLOYMENT_LABEL = "current";
|
||||
export const CURRENT_UNMANAGED_DEPLOYMENT_LABEL = "current-unmanaged";
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Attempts to parse a string into a valid Date.
|
||||
*
|
||||
* Supported formats:
|
||||
* - ISO and RFC date strings (e.g. "2025-08-18", "2025-08-18T12:34:56Z")
|
||||
* - Natural language dates supported by JS Date (e.g. "August 18, 2025")
|
||||
* - Epoch seconds (10-digit numeric string, e.g. "1629302400")
|
||||
* - Epoch milliseconds (13-digit numeric string, e.g. "1629302400000")
|
||||
*
|
||||
* @param input The string to parse.
|
||||
* @returns A valid Date object, or undefined if parsing fails.
|
||||
*/
|
||||
export function parseDate(input: string): Date | undefined {
|
||||
if (typeof input !== "string") return undefined;
|
||||
|
||||
// Handle pure numeric strings as epoch values
|
||||
if (/^\d+$/.test(input)) {
|
||||
const num = Number(input);
|
||||
|
||||
if (input.length === 10) {
|
||||
// Epoch seconds
|
||||
return new Date(num * 1000);
|
||||
} else if (input.length === 13) {
|
||||
// Epoch milliseconds
|
||||
return new Date(num);
|
||||
} else {
|
||||
// Unsupported numeric length
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle general date strings
|
||||
const date = new Date(input);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Parses a natural language duration string into milliseconds.
|
||||
*
|
||||
* @param duration - Duration string like "1s", "5m", "2h", "1d", "1w"
|
||||
* @returns The duration in milliseconds, or undefined if invalid
|
||||
*
|
||||
* @example
|
||||
* parseNaturalLanguageDurationInMs("30m") // 1800000
|
||||
* parseNaturalLanguageDurationInMs("2h") // 7200000
|
||||
*/
|
||||
export function parseNaturalLanguageDurationInMs(duration: string): number | undefined {
|
||||
// Handle Code scanning alert #44 by limiting the length of the input string
|
||||
if (duration.length > 100) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// More flexible regex that captures all units individually regardless of order
|
||||
const weekMatch = duration.match(/(\d+)w/);
|
||||
const dayMatch = duration.match(/(\d+)d/);
|
||||
const hourMatch = duration.match(/(\d+)(?:hr|h)/);
|
||||
const minuteMatch = duration.match(/(\d+)m/);
|
||||
const secondMatch = duration.match(/(\d+)s/);
|
||||
|
||||
// Check if the entire string consists only of valid duration units
|
||||
const validPattern = /^(\d+(?:w|d|hr|h|m|s))+$/;
|
||||
if (!validPattern.test(duration)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let totalMilliseconds = 0;
|
||||
let hasMatch = false;
|
||||
|
||||
if (weekMatch) {
|
||||
const weeks = Number(weekMatch[1]);
|
||||
if (weeks >= 0) {
|
||||
totalMilliseconds += weeks * 7 * 24 * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dayMatch) {
|
||||
const days = Number(dayMatch[1]);
|
||||
if (days >= 0) {
|
||||
totalMilliseconds += days * 24 * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hourMatch) {
|
||||
const hours = Number(hourMatch[1]);
|
||||
if (hours >= 0) {
|
||||
totalMilliseconds += hours * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (minuteMatch) {
|
||||
const minutes = Number(minuteMatch[1]);
|
||||
if (minutes >= 0) {
|
||||
totalMilliseconds += minutes * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (secondMatch) {
|
||||
const seconds = Number(secondMatch[1]);
|
||||
if (seconds >= 0) {
|
||||
totalMilliseconds += seconds * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasMatch ? totalMilliseconds : undefined;
|
||||
}
|
||||
|
||||
export function parseNaturalLanguageDuration(duration: string): Date | undefined {
|
||||
const ms = parseNaturalLanguageDurationInMs(duration);
|
||||
return ms !== undefined ? new Date(Date.now() + ms) : undefined;
|
||||
}
|
||||
|
||||
export function safeParseNaturalLanguageDuration(duration: string): Date | undefined {
|
||||
try {
|
||||
return parseNaturalLanguageDuration(duration);
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
export function parseNaturalLanguageDurationAgo(duration: string): Date | undefined {
|
||||
// Handle Code scanning alert #44 (https://github.com/triggerdotdev/trigger.dev/security/code-scanning/44) by limiting the length of the input string
|
||||
if (duration.length > 100) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// More flexible regex that captures all units individually regardless of order
|
||||
const weekMatch = duration.match(/(\d+)w/);
|
||||
const dayMatch = duration.match(/(\d+)d/);
|
||||
const hourMatch = duration.match(/(\d+)(?:hr|h)/);
|
||||
const minuteMatch = duration.match(/(\d+)m/);
|
||||
const secondMatch = duration.match(/(\d+)s/);
|
||||
|
||||
// Check if the entire string consists only of valid duration units
|
||||
const validPattern = /^(\d+(?:w|d|hr|h|m|s))+$/;
|
||||
if (!validPattern.test(duration)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let totalMilliseconds = 0;
|
||||
let hasMatch = false;
|
||||
|
||||
if (weekMatch) {
|
||||
const weeks = Number(weekMatch[1]);
|
||||
if (weeks >= 0) {
|
||||
totalMilliseconds += weeks * 7 * 24 * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dayMatch) {
|
||||
const days = Number(dayMatch[1]);
|
||||
if (days >= 0) {
|
||||
totalMilliseconds += days * 24 * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hourMatch) {
|
||||
const hours = Number(hourMatch[1]);
|
||||
if (hours >= 0) {
|
||||
totalMilliseconds += hours * 60 * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (minuteMatch) {
|
||||
const minutes = Number(minuteMatch[1]);
|
||||
if (minutes >= 0) {
|
||||
totalMilliseconds += minutes * 60 * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (secondMatch) {
|
||||
const seconds = Number(secondMatch[1]);
|
||||
if (seconds >= 0) {
|
||||
totalMilliseconds += seconds * 1000;
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMatch) {
|
||||
return new Date(Date.now() - totalMilliseconds);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function safeParseNaturalLanguageDurationAgo(duration: string): Date | undefined {
|
||||
try {
|
||||
return parseNaturalLanguageDurationAgo(duration);
|
||||
} catch (_error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyDuration(seconds: number): string | undefined {
|
||||
if (seconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const units = {
|
||||
w: Math.floor(seconds / 604800),
|
||||
d: Math.floor((seconds % 604800) / 86400),
|
||||
h: Math.floor((seconds % 86400) / 3600),
|
||||
m: Math.floor((seconds % 3600) / 60),
|
||||
s: Math.floor(seconds % 60),
|
||||
};
|
||||
|
||||
// Filter the units having non-zero values and join them
|
||||
const result: string = Object.entries(units)
|
||||
.filter(([unit, val]) => val != 0)
|
||||
.map(([unit, val]) => `${val}${unit}`)
|
||||
.join("");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RunId,
|
||||
WaitpointId,
|
||||
SnapshotId,
|
||||
QueueId,
|
||||
RUN_OPS_ID_LENGTH,
|
||||
RUN_OPS_ID_REGION_INDEX,
|
||||
RUN_OPS_ID_VERSION,
|
||||
RUN_OPS_ID_VERSION_INDEX,
|
||||
base32hexDecode,
|
||||
base32hexEncode,
|
||||
generateRunOpsId,
|
||||
parseRunId,
|
||||
} from "./friendlyId.js";
|
||||
|
||||
const CUID_LEN = 25;
|
||||
|
||||
describe("RunId + WaitpointId mint cuid by default; run-ops v1 via generateRunOpsId", () => {
|
||||
it("default: run + waitpoint mint cuid (25) and round-trip", () => {
|
||||
for (const util of [RunId, WaitpointId]) {
|
||||
const { id, friendlyId } = util.generate();
|
||||
expect(id.length).toBe(CUID_LEN);
|
||||
expect(util.fromFriendlyId(friendlyId)).toBe(id);
|
||||
expect(util.toId(friendlyId)).toBe(id);
|
||||
expect(util.toId(id)).toBe(id);
|
||||
expect(util.toFriendlyId(id)).toBe(friendlyId);
|
||||
}
|
||||
});
|
||||
|
||||
it("explicit run-ops id: a run/waitpoint friendlyId over generateRunOpsId() is 26-char and round-trips", () => {
|
||||
for (const util of [RunId, WaitpointId]) {
|
||||
const id = generateRunOpsId();
|
||||
const friendlyId = util.toFriendlyId(id);
|
||||
expect(id.length).toBe(RUN_OPS_ID_LENGTH);
|
||||
expect(util.fromFriendlyId(friendlyId)).toBe(id);
|
||||
expect(util.toId(friendlyId)).toBe(id);
|
||||
expect(util.toId(id)).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("SnapshotId + QueueId stay cuid (25)", () => {
|
||||
expect(SnapshotId.generate().id.length).toBe(CUID_LEN);
|
||||
expect(QueueId.generate().id.length).toBe(CUID_LEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("base32hex codec (lowercase RFC 4648 §7)", () => {
|
||||
// Independent reference: interpret the bytes as one big-endian integer and
|
||||
// emit base-32 digits. Only exact multiples of 5 bytes (40 bits) are used, so
|
||||
// there is never a partial trailing group to disagree on.
|
||||
const ALPHA = "0123456789abcdefghijklmnopqrstuv";
|
||||
function referenceEncode(bytes: Uint8Array): string {
|
||||
let n = 0n;
|
||||
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
||||
const chars = (bytes.length * 8) / 5;
|
||||
let out = "";
|
||||
for (let i = 0; i < chars; i++) {
|
||||
out = ALPHA[Number(n & 31n)] + out;
|
||||
n >>= 5n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("matches the big-integer reference bit-for-bit (property, 5/10/15/20-byte inputs)", () => {
|
||||
for (let iter = 0; iter < 2_000; iter++) {
|
||||
for (const len of [5, 10, 15, 20]) {
|
||||
const bytes = new Uint8Array(len);
|
||||
crypto.getRandomValues(bytes);
|
||||
const encoded = base32hexEncode(bytes);
|
||||
expect(encoded).toBe(referenceEncode(bytes));
|
||||
expect(Array.from(base32hexDecode(encoded))).toEqual(Array.from(bytes));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hand-verified vectors", () => {
|
||||
expect(base32hexEncode(new Uint8Array(5))).toBe("00000000");
|
||||
expect(base32hexEncode(new Uint8Array(5).fill(0xff))).toBe("vvvvvvvv");
|
||||
expect(base32hexEncode(new Uint8Array([0, 0, 0, 0, 1]))).toBe("00000001");
|
||||
// 0x20 0 0 0 0 = 2^37; 2^37 / 32^7 = 4 → leading digit "4"
|
||||
expect(base32hexEncode(new Uint8Array([0x20, 0, 0, 0, 0]))).toBe("40000000");
|
||||
});
|
||||
|
||||
it("decode rejects characters outside the lowercase base32hex alphabet", () => {
|
||||
for (const bad of ["w", "x", "z", "A", "V", "-", "_", " "]) {
|
||||
expect(() => base32hexDecode(`0000000${bad}`)).toThrow(/invalid/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateRunOpsId — run-ops v1 id spec", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("emits <24-char base32hex core><region char><version '1'> — 26 chars total", () => {
|
||||
const id = generateRunOpsId();
|
||||
expect(id.length).toBe(RUN_OPS_ID_LENGTH);
|
||||
expect(id).toMatch(/^[0-9a-v]{24}[a-z0-9]1$/);
|
||||
expect(id[RUN_OPS_ID_VERSION_INDEX]).toBe(RUN_OPS_ID_VERSION);
|
||||
});
|
||||
|
||||
it("only ever uses lowercase [a-z0-9] and NEVER '-' (DNS-1123 / pod-name invariant)", () => {
|
||||
for (let i = 0; i < 5_000; i++) {
|
||||
const id = generateRunOpsId();
|
||||
expect(id).toMatch(/^[a-z0-9]+$/);
|
||||
expect(id).not.toContain("-");
|
||||
}
|
||||
});
|
||||
|
||||
it("stamps the region char from REGION_CODES, defaulting to '0' when unknown/absent", () => {
|
||||
expect(generateRunOpsId("us-east-1")[RUN_OPS_ID_REGION_INDEX]).toBe("e");
|
||||
expect(generateRunOpsId("us-west-2")[RUN_OPS_ID_REGION_INDEX]).toBe("w");
|
||||
expect(generateRunOpsId("eu-central-1")[RUN_OPS_ID_REGION_INDEX]).toBe("c");
|
||||
expect(generateRunOpsId("mars-north-1")[RUN_OPS_ID_REGION_INDEX]).toBe("0");
|
||||
expect(generateRunOpsId()[RUN_OPS_ID_REGION_INDEX]).toBe("0");
|
||||
});
|
||||
|
||||
it("sorts lexicographically in creation order at ms resolution (A@t, C@t+3ms, B@t+1s → A,C,B)", () => {
|
||||
vi.useFakeTimers();
|
||||
const t = new Date("2026-07-04T12:00:00.000Z").getTime();
|
||||
vi.setSystemTime(t);
|
||||
const a = generateRunOpsId();
|
||||
vi.setSystemTime(t + 1000);
|
||||
const b = generateRunOpsId();
|
||||
vi.setSystemTime(t + 3);
|
||||
const c = generateRunOpsId();
|
||||
expect([b, c, a].sort()).toEqual([a, c, b]);
|
||||
});
|
||||
|
||||
it("decode recovers the exact ms timestamp", () => {
|
||||
vi.useFakeTimers();
|
||||
const t = new Date("2026-07-04T12:34:56.789Z");
|
||||
vi.setSystemTime(t);
|
||||
const parsed = parseRunId(`run_${generateRunOpsId("us-east-1")}`);
|
||||
expect(parsed.format).toBe("b32hex");
|
||||
if (parsed.format === "b32hex") {
|
||||
expect(parsed.timestamp.getTime()).toBe(t.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it("is unique across many mints in the same ms (72 bits of CSPRNG)", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-04T00:00:00.000Z"));
|
||||
const n = 2_000;
|
||||
expect(new Set(Array.from({ length: n }, () => generateRunOpsId())).size).toBe(n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRunId — version-char discrimination (not length)", () => {
|
||||
it("parses a v1 friendly id as partitioned with region + version", () => {
|
||||
const parsed = parseRunId(`run_${generateRunOpsId("us-west-2")}`);
|
||||
expect(parsed).toMatchObject({
|
||||
format: "b32hex",
|
||||
table: "partitioned",
|
||||
region: "w",
|
||||
version: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a cuid friendly id legacy", () => {
|
||||
expect(parseRunId(RunId.generate().friendlyId)).toEqual({
|
||||
format: "legacy",
|
||||
table: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a nanoid-bodied friendly id and a run_-less id legacy", () => {
|
||||
expect(parseRunId("run_123456789abcdefghijkm").format).toBe("legacy"); // 21-char nanoid body
|
||||
expect(parseRunId(generateRunOpsId()).format).toBe("legacy"); // bare body, no run_ prefix
|
||||
expect(parseRunId("waitpoint_" + generateRunOpsId()).format).toBe("legacy"); // wrong prefix
|
||||
});
|
||||
|
||||
it("falls back to legacy on a malformed v1 (bad alphabet / wrong version char)", () => {
|
||||
expect(parseRunId(`run_${"A".repeat(25)}1`).format).toBe("legacy"); // uppercase core
|
||||
expect(parseRunId(`run_${"a".repeat(25)}2`).format).toBe("legacy"); // wrong version
|
||||
expect(parseRunId(`run_${"a".repeat(24)}-1`).format).toBe("legacy"); // region char not [a-z0-9]
|
||||
expect(parseRunId(`run_${"a".repeat(27)}`).format).toBe("legacy"); // old 27-char shape
|
||||
});
|
||||
});
|
||||
|
||||
describe("firekeeper pod-name round-trip (runner-<id>[-attempt-N] → run_<id>)", () => {
|
||||
// Mirrors firekeeper's runIDFromPodName: strip "runner-", cut before the first
|
||||
// hyphen, prepend "run_". Works because a v1 id is all-lowercase [0-9a-v] and
|
||||
// NEVER contains "-" — the hyphens all belong to the pod-name delimiters.
|
||||
function firekeeperRunIdFromPodName(name: string): string {
|
||||
const rest = name.slice("runner-".length);
|
||||
const hyphen = rest.indexOf("-");
|
||||
return `run_${hyphen === -1 ? rest : rest.slice(0, hyphen)}`;
|
||||
}
|
||||
|
||||
it("recovers the exact id (incl. region + version chars) from first-attempt and retry pods", () => {
|
||||
const id = generateRunOpsId("us-east-1");
|
||||
expect(firekeeperRunIdFromPodName(`runner-${id}`)).toBe(`run_${id}`);
|
||||
expect(firekeeperRunIdFromPodName(`runner-${id}-attempt-2`)).toBe(`run_${id}`);
|
||||
expect(parseRunId(firekeeperRunIdFromPodName(`runner-${id}-attempt-2`)).format).toBe("b32hex");
|
||||
});
|
||||
|
||||
it("the recovered id is a valid DNS-1123 label body (k8s accepts runner-<id>)", () => {
|
||||
const podName = `runner-${generateRunOpsId("eu-central-1")}`;
|
||||
expect(podName).toMatch(/^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/);
|
||||
expect(podName.length).toBeLessThanOrEqual(63);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
import cuid from "@bugsnag/cuid";
|
||||
|
||||
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
|
||||
|
||||
export function generateFriendlyId(prefix: string, size?: number) {
|
||||
return `${prefix}_${idGenerator(size)}`;
|
||||
}
|
||||
|
||||
// Run-ops v1 id: `<24-char base32hex core><region char><version char>` — 26 chars.
|
||||
// Core = 6-byte big-endian unix ms timestamp + 9 bytes CSPRNG. Invariants:
|
||||
// - alphabet is lowercase [a-z0-9] (base32hex is [0-9a-v]): DNS-1123 safe for
|
||||
// k8s pod names, and byte order == lexicographic order, so ids sort in mint
|
||||
// order at ms resolution;
|
||||
// - the id NEVER contains "-" — that delimiter belongs to pod-name suffixes
|
||||
// (`runner-<id>-attempt-N`), so the id round-trips through a pod name by
|
||||
// cutting at the first hyphen;
|
||||
// - the `run_` (friendly) and `runner-` (pod) prefixes are part of the spec:
|
||||
// they guarantee the k8s name starts with a letter even though a base32hex
|
||||
// core can start with a digit.
|
||||
const RUN_OPS_ID_ALPHABET = "0123456789abcdefghijklmnopqrstuv"; // base32hex, lowercase (RFC 4648 §7)
|
||||
export const RUN_OPS_ID_LENGTH = 26;
|
||||
export const RUN_OPS_ID_REGION_INDEX = 24;
|
||||
export const RUN_OPS_ID_VERSION_INDEX = 25;
|
||||
export const RUN_OPS_ID_VERSION = "1";
|
||||
const RUN_OPS_ID_CORE_BYTES = 15; // 6 timestamp + 9 random → exactly 24 base32hex chars
|
||||
const RUN_OPS_ID_CORE_LENGTH = 24;
|
||||
const RUN_OPS_ID_TIMESTAMP_BYTES = 6;
|
||||
|
||||
/** Region char stamped when the region is unknown or unmapped at mint. */
|
||||
export const DEFAULT_REGION_CHAR = "0";
|
||||
// The region char is a raw positional char (readable via charAt before any
|
||||
// decoding), NOT part of the base32hex core — so it may use the full DNS-safe
|
||||
// lowercase [a-z0-9] range (e.g. "w" for us-west-2, which is outside [0-9a-v]).
|
||||
const REGION_CHAR_PATTERN = /^[a-z0-9]$/;
|
||||
/** One lowercase [a-z0-9] char per supported region, at RUN_OPS_ID_REGION_INDEX. */
|
||||
export const REGION_CODES: Readonly<Record<string, string>> = {
|
||||
"us-east-1": "e",
|
||||
"us-west-2": "w",
|
||||
"eu-central-1": "c",
|
||||
};
|
||||
|
||||
export function regionCharForRegion(region: string | undefined): string {
|
||||
return (region && REGION_CODES[region]) || DEFAULT_REGION_CHAR;
|
||||
}
|
||||
|
||||
// globalThis.crypto is absent on Node 18.20 (a supported engine) without a flag, so fall back to
|
||||
// node:crypto's webcrypto, loaded only when the global is missing to stay isomorphic.
|
||||
type RandomFiller = (array: Uint8Array) => void;
|
||||
|
||||
function resolveGetRandomValues(): RandomFiller {
|
||||
const globalCrypto = (globalThis as { crypto?: Crypto }).crypto;
|
||||
if (globalCrypto?.getRandomValues) {
|
||||
return (array) => globalCrypto.getRandomValues(array);
|
||||
}
|
||||
const webcrypto = loadNodeWebCrypto();
|
||||
if (webcrypto?.getRandomValues) {
|
||||
return (array) => webcrypto.getRandomValues(array);
|
||||
}
|
||||
throw new Error("No Web Crypto getRandomValues implementation available");
|
||||
}
|
||||
|
||||
function loadNodeWebCrypto(): Crypto | undefined {
|
||||
try {
|
||||
return (typeof require === "function" ? require("node:crypto") : undefined)?.webcrypto;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the crypto source lazily on first use (memoized), so merely importing this
|
||||
// widely-used module never throws when crypto is unavailable — only minting an id would.
|
||||
let cachedGetRandomValues: RandomFiller | undefined;
|
||||
const getRandomValues: RandomFiller = (array) =>
|
||||
(cachedGetRandomValues ??= resolveGetRandomValues())(array);
|
||||
|
||||
/** Lowercase base32hex (RFC 4648 §7): 5 bits per char, order-preserving, no padding. */
|
||||
export function base32hexEncode(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
let buf = 0;
|
||||
let bits = 0;
|
||||
for (const b of bytes) {
|
||||
buf = (buf << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += RUN_OPS_ID_ALPHABET[(buf >> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inverse of base32hexEncode. Throws on characters outside the lowercase alphabet. */
|
||||
export function base32hexDecode(s: string): Uint8Array {
|
||||
const out: number[] = [];
|
||||
let buf = 0;
|
||||
let bits = 0;
|
||||
for (const c of s) {
|
||||
const v = RUN_OPS_ID_ALPHABET.indexOf(c);
|
||||
if (v === -1) {
|
||||
throw new Error(`invalid run id char: ${c}`);
|
||||
}
|
||||
buf = (buf << 5) | v;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((buf >> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a run-ops v1 id body (26 chars, no prefix): 24-char base32hex core
|
||||
* (6-byte ms timestamp + 9 CSPRNG bytes) + region char + version char "1".
|
||||
* The trailing version char at RUN_OPS_ID_VERSION_INDEX is the residency
|
||||
* discriminator — see runOpsResidency.ts.
|
||||
*/
|
||||
export function generateRunOpsId(region?: string): string {
|
||||
const core = new Uint8Array(RUN_OPS_ID_CORE_BYTES);
|
||||
|
||||
let ms = Date.now();
|
||||
for (let i = RUN_OPS_ID_TIMESTAMP_BYTES - 1; i >= 0; i--) {
|
||||
core[i] = ms % 256;
|
||||
ms = Math.floor(ms / 256);
|
||||
}
|
||||
getRandomValues(core.subarray(RUN_OPS_ID_TIMESTAMP_BYTES));
|
||||
|
||||
return `${base32hexEncode(core)}${regionCharForRegion(region)}${RUN_OPS_ID_VERSION}`;
|
||||
}
|
||||
|
||||
export type ParsedRunId =
|
||||
| { format: "b32hex"; table: "partitioned"; timestamp: Date; region: string; version: string }
|
||||
| { format: "legacy"; table: "legacy" };
|
||||
|
||||
const LEGACY_RUN_ID: ParsedRunId = { format: "legacy", table: "legacy" };
|
||||
|
||||
/**
|
||||
* Parse a v1 id body (no prefix). Returns undefined unless the body is exactly
|
||||
* 26 chars with version "1" at index 25 and every char inside the base32hex
|
||||
* alphabet — anything else (cuid, nanoid, pre-cutover 27-char base62, malformed
|
||||
* v1) is a legacy shape.
|
||||
*/
|
||||
export function parseRunOpsIdBody(
|
||||
body: string
|
||||
): { timestamp: Date; region: string; version: string } | undefined {
|
||||
if (body.length !== RUN_OPS_ID_LENGTH) return undefined;
|
||||
if (body[RUN_OPS_ID_VERSION_INDEX] !== RUN_OPS_ID_VERSION) return undefined;
|
||||
const region = body[RUN_OPS_ID_REGION_INDEX] ?? "";
|
||||
if (!REGION_CHAR_PATTERN.test(region)) return undefined;
|
||||
|
||||
let core: Uint8Array;
|
||||
try {
|
||||
core = base32hexDecode(body.slice(0, RUN_OPS_ID_CORE_LENGTH));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let ms = 0;
|
||||
for (let i = 0; i < RUN_OPS_ID_TIMESTAMP_BYTES; i++) {
|
||||
ms = ms * 256 + (core[i] ?? 0);
|
||||
}
|
||||
|
||||
return { timestamp: new Date(ms), region, version: RUN_OPS_ID_VERSION };
|
||||
}
|
||||
|
||||
/** True if the (prefixless) id body is a well-formed run-ops v1 id. */
|
||||
export function isRunOpsIdBody(body: string): boolean {
|
||||
return parseRunOpsIdBody(body) !== undefined;
|
||||
}
|
||||
|
||||
/** Parse a `run_`-prefixed friendly id; anything not a well-formed v1 id is legacy. */
|
||||
export function parseRunId(id: string): ParsedRunId {
|
||||
if (!id.startsWith("run_")) return LEGACY_RUN_ID;
|
||||
const parsed = parseRunOpsIdBody(id.slice(4));
|
||||
return parsed ? { format: "b32hex", table: "partitioned", ...parsed } : LEGACY_RUN_ID;
|
||||
}
|
||||
|
||||
export function generateInternalId(): string {
|
||||
return cuid();
|
||||
}
|
||||
|
||||
/** Convert an internal ID to a friendly ID */
|
||||
export function toFriendlyId(entityName: string, internalId: string): string {
|
||||
if (!entityName) {
|
||||
throw new Error("Entity name cannot be empty");
|
||||
}
|
||||
|
||||
if (!internalId) {
|
||||
throw new Error("Internal ID cannot be empty");
|
||||
}
|
||||
|
||||
if (internalId.startsWith(`${entityName}_`)) {
|
||||
return internalId;
|
||||
}
|
||||
|
||||
return `${entityName}_${internalId}`;
|
||||
}
|
||||
|
||||
/** Convert a friendly ID to an internal ID */
|
||||
export function fromFriendlyId(friendlyId: string, expectedEntityName?: string): string {
|
||||
if (!friendlyId) {
|
||||
throw new Error("Friendly ID cannot be empty");
|
||||
}
|
||||
|
||||
const parts = friendlyId.split("_");
|
||||
|
||||
if (parts.length !== 2) {
|
||||
throw new Error("Invalid friendly ID format");
|
||||
}
|
||||
|
||||
const [entityName, internalId] = parts;
|
||||
|
||||
if (!entityName) {
|
||||
throw new Error("Entity name cannot be empty");
|
||||
}
|
||||
|
||||
if (!internalId) {
|
||||
throw new Error("Internal ID cannot be empty");
|
||||
}
|
||||
|
||||
if (expectedEntityName && entityName !== expectedEntityName) {
|
||||
throw new Error(`Invalid entity name: ${entityName}`);
|
||||
}
|
||||
|
||||
return internalId;
|
||||
}
|
||||
|
||||
export class IdUtil {
|
||||
constructor(private entityName: string) {}
|
||||
|
||||
generate() {
|
||||
const internalId = generateInternalId();
|
||||
|
||||
return {
|
||||
id: internalId,
|
||||
friendlyId: this.toFriendlyId(internalId),
|
||||
};
|
||||
}
|
||||
|
||||
toFriendlyId(internalId: string) {
|
||||
return toFriendlyId(this.entityName, internalId);
|
||||
}
|
||||
|
||||
fromFriendlyId(friendlyId: string) {
|
||||
return fromFriendlyId(friendlyId);
|
||||
}
|
||||
|
||||
/** Will convert friendlyIds, and will leave ids as they are */
|
||||
toId(value: string) {
|
||||
if (value.startsWith(`${this.entityName}_`)) {
|
||||
return fromFriendlyId(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const BackgroundWorkerId = new IdUtil("worker");
|
||||
export const CheckpointId = new IdUtil("checkpoint");
|
||||
export const QueueId = new IdUtil("queue");
|
||||
export const RunId = new IdUtil("run");
|
||||
export const SnapshotId = new IdUtil("snapshot");
|
||||
export const WaitpointId = new IdUtil("waitpoint");
|
||||
export const BatchId = new IdUtil("batch");
|
||||
export const BulkActionId = new IdUtil("bulk");
|
||||
export const AttemptId = new IdUtil("attempt");
|
||||
export const ErrorId = new IdUtil("error");
|
||||
export const SessionId = new IdUtil("session");
|
||||
|
||||
export class IdGenerator {
|
||||
private alphabet: string;
|
||||
private length: number;
|
||||
private prefix: string;
|
||||
|
||||
constructor({ alphabet, length, prefix }: { alphabet: string; length: number; prefix: string }) {
|
||||
this.alphabet = alphabet;
|
||||
this.length = length;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
generate(): string {
|
||||
return `${this.prefix}${customAlphabet(this.alphabet, this.length)()}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./friendlyId.js";
|
||||
export * from "./runOpsResidency.js";
|
||||
export * from "./duration.js";
|
||||
export * from "./maxDuration.js";
|
||||
export * from "./queueName.js";
|
||||
export * from "./consts.js";
|
||||
export * from "./traceContext.js";
|
||||
export * from "./dates.js";
|
||||
@@ -0,0 +1,22 @@
|
||||
const MINIMUM_MAX_DURATION = 5;
|
||||
const MAXIMUM_MAX_DURATION = 2_147_483_647; // largest 32-bit signed integer
|
||||
|
||||
export function clampMaxDuration(maxDuration: number): number {
|
||||
return Math.min(Math.max(maxDuration, MINIMUM_MAX_DURATION), MAXIMUM_MAX_DURATION);
|
||||
}
|
||||
|
||||
export function getMaxDuration(
|
||||
maxDuration?: number | null,
|
||||
defaultMaxDuration?: number | null
|
||||
): number | undefined {
|
||||
if (!maxDuration) {
|
||||
return defaultMaxDuration ?? undefined;
|
||||
}
|
||||
|
||||
// Setting the maxDuration to MAXIMUM_MAX_DURATION means we don't want to use the default maxDuration
|
||||
if (maxDuration === MAXIMUM_MAX_DURATION) {
|
||||
return;
|
||||
}
|
||||
|
||||
return maxDuration;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Only allow alphanumeric characters, underscores, hyphens, and slashes (and only the first 128 characters)
|
||||
export function sanitizeQueueName(queueName: string) {
|
||||
return queueName.replace(/[^a-zA-Z0-9_\-/]/g, "").substring(0, 128);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RunId, WaitpointId, BatchId, SnapshotId, generateRunOpsId } from "./friendlyId.js";
|
||||
import { ownerEngine, classifyResidency, classifyKind, isClassifiable } from "./runOpsResidency.js";
|
||||
|
||||
const SAMPLES = 50_000; // property-scale; CI-fast. (Bump locally toward "millions" for deeper coverage.)
|
||||
|
||||
describe("ownerEngine — residency classifier (version char at fixed position, not length)", () => {
|
||||
it("cuid ids (default mint) classify LEGACY, friendly + internal", () => {
|
||||
for (const util of [RunId, WaitpointId]) {
|
||||
const { id, friendlyId } = util.generate();
|
||||
expect(ownerEngine(id)).toBe("LEGACY");
|
||||
expect(ownerEngine(friendlyId)).toBe("LEGACY"); // strips run_/waitpoint_ prefix
|
||||
expect(classifyResidency(id)).toBe("LEGACY"); // alias agrees
|
||||
expect(classifyKind(id)).toBe("cuid");
|
||||
expect(isClassifiable(id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("run-ops v1 ids (generateRunOpsId) classify NEW, friendly + internal, across id-shape co-located entities", () => {
|
||||
for (const util of [RunId, WaitpointId, BatchId]) {
|
||||
const id = generateRunOpsId("us-east-1");
|
||||
const friendlyId = util.toFriendlyId(id);
|
||||
expect(ownerEngine(id)).toBe("NEW");
|
||||
expect(ownerEngine(friendlyId)).toBe("NEW");
|
||||
expect(classifyResidency(id)).toBe("NEW");
|
||||
expect(classifyKind(id)).toBe("runOpsId");
|
||||
}
|
||||
});
|
||||
|
||||
it("discriminates on the version char, not length: 26+'1' → NEW, 26+'2' → LEGACY", () => {
|
||||
const v1 = "a".repeat(24) + "e1";
|
||||
expect(ownerEngine(v1)).toBe("NEW");
|
||||
expect(ownerEngine("a".repeat(24) + "e2")).toBe("LEGACY");
|
||||
expect(ownerEngine("a".repeat(26))).toBe("LEGACY"); // 26 chars but no version marker
|
||||
});
|
||||
|
||||
it("malformed v1 shapes fall back to LEGACY (never throw)", () => {
|
||||
for (const bad of [
|
||||
"",
|
||||
"x".repeat(24) + "01", // 'x' outside base32hex
|
||||
"A".repeat(25) + "1", // uppercase
|
||||
"a".repeat(24) + "-1", // hyphen region char
|
||||
"a".repeat(27), // pre-cutover 27-char shape → LEGACY under the version rule
|
||||
"run_" + "b".repeat(27), // 27-char base62 pre-cutover friendly id → LEGACY
|
||||
"x".repeat(40),
|
||||
]) {
|
||||
expect(ownerEngine(bad)).toBe("LEGACY");
|
||||
expect(isClassifiable(bad)).toBe(true); // classification is total now
|
||||
}
|
||||
});
|
||||
|
||||
it("disjointness: no cuid sample is ever NEW, no v1 sample is ever LEGACY", () => {
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
expect(ownerEngine(RunId.generate().id)).toBe("LEGACY");
|
||||
expect(ownerEngine(generateRunOpsId())).toBe("NEW");
|
||||
}
|
||||
});
|
||||
|
||||
it("SnapshotId (always cuid) classifies LEGACY — proves snapshot needs no residency key", () => {
|
||||
expect(ownerEngine(SnapshotId.generate().id)).toBe("LEGACY");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { isRunOpsIdBody } from "./friendlyId.js";
|
||||
|
||||
/** The two run-ops stores a run/waitpoint can reside in. */
|
||||
export type Residency = "LEGACY" | "NEW";
|
||||
|
||||
/**
|
||||
* Underlying id lineage. "runOpsId" is the label for the NEW-store mint path
|
||||
* — a base32hex run-ops v1 id (see friendlyId.ts). It is the value persisted in
|
||||
* the runOpsMintKind feature flag. "cuid" is every legacy shape (cuid, nanoid,
|
||||
* and the pre-cutover 27-char base62 format).
|
||||
*/
|
||||
export type ResidencyKind = "cuid" | "runOpsId";
|
||||
|
||||
/** @bugsnag/cuid emits 25-char ids (legacy mint path, flag OFF). */
|
||||
export const CUID_LENGTH = 25;
|
||||
|
||||
/**
|
||||
* Kept for API compatibility: the default classifier no longer throws (every
|
||||
* non-v1 shape is legacy), but injected classifiers may still raise it and
|
||||
* callers still catch it.
|
||||
*/
|
||||
export class UnclassifiableRunId extends Error {
|
||||
readonly value: string;
|
||||
readonly valueLength: number;
|
||||
constructor(value: string) {
|
||||
super(`Unclassifiable run-ops id: value=${JSON.stringify(value)} (length ${value.length})`);
|
||||
this.name = "UnclassifiableRunId";
|
||||
this.value = value;
|
||||
this.valueLength = value.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a single leading `<prefix>_` (e.g. `run_`, `waitpoint_`) if present,
|
||||
* so friendly and internal forms classify identically. Only the FIRST
|
||||
* underscore is treated as the prefix separator (mirrors fromFriendlyId's
|
||||
* two-part split contract in friendlyId.ts), without importing it.
|
||||
*/
|
||||
function internalForm(id: string): string {
|
||||
const underscore = id.indexOf("_");
|
||||
return underscore === -1 ? id : id.slice(underscore + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id lineage by the version-char rule: a well-formed run-ops v1
|
||||
* body (26 chars, version "1" at index 25, base32hex alphabet) is "runOpsId"
|
||||
* (NEW store); everything else — including malformed v1 shapes — is "cuid"
|
||||
* (legacy). Total: never throws. Transition: pre-cutover 27-char base62 ids (the old
|
||||
* NEW-mint format) now classify LEGACY, so ship this with the base32hex generator only once
|
||||
* any 27-char NEW-resident runs are drained/disposable — no live run is misrouted mid-cutover.
|
||||
*/
|
||||
export function classifyKind(id: string): ResidencyKind {
|
||||
return isRunOpsIdBody(internalForm(id)) ? "runOpsId" : "cuid";
|
||||
}
|
||||
|
||||
/** Classification is total now; kept for API compatibility. */
|
||||
export function isClassifiable(_id: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Map an id to its owning run-ops store by the version-char rule. */
|
||||
export function classifyResidency(id: string): Residency {
|
||||
return classifyKind(id) === "runOpsId" ? "NEW" : "LEGACY";
|
||||
}
|
||||
|
||||
/** Primary public name (RoutingRunStore / cross-seam guard). */
|
||||
export const ownerEngine = classifyResidency;
|
||||
@@ -0,0 +1,29 @@
|
||||
export function parseTraceparent(
|
||||
traceparent?: string
|
||||
): { traceId: string; spanId: string; traceFlags?: string } | undefined {
|
||||
if (!traceparent) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = traceparent.split("-");
|
||||
|
||||
if (parts.length !== 4) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [version, traceId, spanId, traceFlags] = parts;
|
||||
|
||||
if (version !== "00") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!traceId || !spanId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { traceId, spanId, traceFlags };
|
||||
}
|
||||
|
||||
export function serializeTraceparent(traceId: string, spanId: string, traceFlags?: string) {
|
||||
return `00-${traceId}-${spanId}-${traceFlags ?? "01"}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { JWTPayload } from "jose";
|
||||
|
||||
export type GenerateJWTOptions = {
|
||||
secretKey: string;
|
||||
payload: Record<string, any>;
|
||||
expirationTime?: number | Date | string;
|
||||
};
|
||||
|
||||
export const JWT_ALGORITHM = "HS256";
|
||||
export const JWT_ISSUER = "https://id.trigger.dev";
|
||||
export const JWT_AUDIENCE = "https://api.trigger.dev";
|
||||
|
||||
export async function generateJWT(options: GenerateJWTOptions): Promise<string> {
|
||||
const { SignJWT } = await import("jose");
|
||||
|
||||
const secret = new TextEncoder().encode(options.secretKey);
|
||||
|
||||
return new SignJWT(options.payload)
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setProtectedHeader({ alg: JWT_ALGORITHM })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(options.expirationTime ?? "15m")
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
payload: JWTPayload;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export async function validateJWT(token: string, apiKey: string): Promise<ValidationResult> {
|
||||
const { jwtVerify, errors } = await import("jose");
|
||||
|
||||
const secret = new TextEncoder().encode(apiKey);
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret, {
|
||||
issuer: JWT_ISSUER,
|
||||
audience: JWT_AUDIENCE,
|
||||
});
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (error) {
|
||||
if (error instanceof errors.JOSEError) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
code: "ERR_UNKNOWN",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { LifecycleHooksAPI } from "./lifecycleHooks/index.js";
|
||||
/** Entrypoint for runtime API */
|
||||
export const lifecycleHooks = LifecycleHooksAPI.getInstance();
|
||||
|
||||
export type {
|
||||
OnInitHookFunction,
|
||||
AnyOnInitHookFunction,
|
||||
RegisteredHookFunction,
|
||||
TaskInitHookParams,
|
||||
TaskStartHookParams,
|
||||
OnStartHookFunction,
|
||||
AnyOnStartHookFunction,
|
||||
TaskFailureHookParams,
|
||||
AnyOnFailureHookFunction,
|
||||
TaskSuccessHookParams,
|
||||
AnyOnSuccessHookFunction,
|
||||
TaskCompleteHookParams,
|
||||
AnyOnCompleteHookFunction,
|
||||
TaskWaitHookParams,
|
||||
AnyOnWaitHookFunction,
|
||||
TaskResumeHookParams,
|
||||
AnyOnResumeHookFunction,
|
||||
TaskCatchErrorHookParams,
|
||||
AnyOnCatchErrorHookFunction,
|
||||
TaskCompleteResult,
|
||||
TaskMiddlewareHookParams,
|
||||
AnyOnMiddlewareHookFunction,
|
||||
OnMiddlewareHookFunction,
|
||||
OnCleanupHookFunction,
|
||||
AnyOnCleanupHookFunction,
|
||||
TaskCleanupHookParams,
|
||||
TaskWait,
|
||||
TaskCancelHookParams,
|
||||
OnCancelHookFunction,
|
||||
AnyOnCancelHookFunction,
|
||||
AnyOnStartAttemptHookFunction,
|
||||
} from "./lifecycleHooks/types.js";
|
||||
@@ -0,0 +1,316 @@
|
||||
const API_NAME = "lifecycle-hooks";
|
||||
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import { NoopLifecycleHooksManager } from "./manager.js";
|
||||
import {
|
||||
type AnyOnCatchErrorHookFunction,
|
||||
type AnyOnCleanupHookFunction,
|
||||
type AnyOnCompleteHookFunction,
|
||||
type AnyOnFailureHookFunction,
|
||||
type AnyOnInitHookFunction,
|
||||
type AnyOnMiddlewareHookFunction,
|
||||
type AnyOnResumeHookFunction,
|
||||
type AnyOnStartHookFunction,
|
||||
type AnyOnSuccessHookFunction,
|
||||
type AnyOnWaitHookFunction,
|
||||
type AnyOnCancelHookFunction,
|
||||
type RegisteredHookFunction,
|
||||
type RegisterHookFunctionParams,
|
||||
type TaskWait,
|
||||
type AnyOnStartAttemptHookFunction,
|
||||
type LifecycleHooksManager,
|
||||
} from "./types.js";
|
||||
|
||||
const NOOP_LIFECYCLE_HOOKS_MANAGER = new NoopLifecycleHooksManager();
|
||||
|
||||
export class LifecycleHooksAPI {
|
||||
private static _instance?: LifecycleHooksAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LifecycleHooksAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new LifecycleHooksAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public setGlobalLifecycleHooksManager(lifecycleHooksManager: LifecycleHooksManager): boolean {
|
||||
return registerGlobal(API_NAME, lifecycleHooksManager);
|
||||
}
|
||||
|
||||
public disable() {
|
||||
unregisterGlobal(API_NAME);
|
||||
}
|
||||
|
||||
public registerGlobalInitHook(hook: RegisterHookFunctionParams<AnyOnInitHookFunction>): void {
|
||||
this.#getManager().registerGlobalInitHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskInitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnInitHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskInitHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskInitHook(taskId: string): AnyOnInitHookFunction | undefined {
|
||||
return this.#getManager().getTaskInitHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalInitHooks(): RegisteredHookFunction<AnyOnInitHookFunction>[] {
|
||||
return this.#getManager().getGlobalInitHooks();
|
||||
}
|
||||
|
||||
public registerTaskStartHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskStartHook(taskId, hook);
|
||||
}
|
||||
|
||||
public registerGlobalStartHook(hook: RegisterHookFunctionParams<AnyOnStartHookFunction>): void {
|
||||
this.#getManager().registerGlobalStartHook(hook);
|
||||
}
|
||||
|
||||
public getTaskStartHook(taskId: string): AnyOnStartHookFunction | undefined {
|
||||
return this.#getManager().getTaskStartHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalStartHooks(): RegisteredHookFunction<AnyOnStartHookFunction>[] {
|
||||
return this.#getManager().getGlobalStartHooks();
|
||||
}
|
||||
|
||||
public registerTaskStartAttemptHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskStartAttemptHook(taskId, hook);
|
||||
}
|
||||
|
||||
public registerGlobalStartAttemptHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalStartAttemptHook(hook);
|
||||
}
|
||||
|
||||
public getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined {
|
||||
return this.#getManager().getTaskStartAttemptHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalStartAttemptHooks(): RegisteredHookFunction<AnyOnStartAttemptHookFunction>[] {
|
||||
return this.#getManager().getGlobalStartAttemptHooks();
|
||||
}
|
||||
|
||||
public registerGlobalFailureHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalFailureHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskFailureHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskFailureHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskFailureHook(taskId: string): AnyOnFailureHookFunction | undefined {
|
||||
return this.#getManager().getTaskFailureHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalFailureHooks(): RegisteredHookFunction<AnyOnFailureHookFunction>[] {
|
||||
return this.#getManager().getGlobalFailureHooks();
|
||||
}
|
||||
|
||||
public registerGlobalSuccessHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalSuccessHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskSuccessHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskSuccessHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskSuccessHook(taskId: string): AnyOnSuccessHookFunction | undefined {
|
||||
return this.#getManager().getTaskSuccessHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalSuccessHooks(): RegisteredHookFunction<AnyOnSuccessHookFunction>[] {
|
||||
return this.#getManager().getGlobalSuccessHooks();
|
||||
}
|
||||
|
||||
public registerGlobalCompleteHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalCompleteHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskCompleteHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskCompleteHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskCompleteHook(taskId: string): AnyOnCompleteHookFunction | undefined {
|
||||
return this.#getManager().getTaskCompleteHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalCompleteHooks(): RegisteredHookFunction<AnyOnCompleteHookFunction>[] {
|
||||
return this.#getManager().getGlobalCompleteHooks();
|
||||
}
|
||||
|
||||
public registerGlobalWaitHook(hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>): void {
|
||||
this.#getManager().registerGlobalWaitHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskWaitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskWaitHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskWaitHook(taskId: string): AnyOnWaitHookFunction | undefined {
|
||||
return this.#getManager().getTaskWaitHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalWaitHooks(): RegisteredHookFunction<AnyOnWaitHookFunction>[] {
|
||||
return this.#getManager().getGlobalWaitHooks();
|
||||
}
|
||||
|
||||
public registerGlobalResumeHook(hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>): void {
|
||||
this.#getManager().registerGlobalResumeHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskResumeHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskResumeHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskResumeHook(taskId: string): AnyOnResumeHookFunction | undefined {
|
||||
return this.#getManager().getTaskResumeHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalResumeHooks(): RegisteredHookFunction<AnyOnResumeHookFunction>[] {
|
||||
return this.#getManager().getGlobalResumeHooks();
|
||||
}
|
||||
|
||||
public registerGlobalCatchErrorHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalCatchErrorHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskCatchErrorHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskCatchErrorHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskCatchErrorHook(taskId: string): AnyOnCatchErrorHookFunction | undefined {
|
||||
return this.#getManager().getTaskCatchErrorHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalCatchErrorHooks(): RegisteredHookFunction<AnyOnCatchErrorHookFunction>[] {
|
||||
return this.#getManager().getGlobalCatchErrorHooks();
|
||||
}
|
||||
|
||||
public registerGlobalMiddlewareHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalMiddlewareHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskMiddlewareHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskMiddlewareHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskMiddlewareHook(taskId: string): AnyOnMiddlewareHookFunction | undefined {
|
||||
return this.#getManager().getTaskMiddlewareHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalMiddlewareHooks(): RegisteredHookFunction<AnyOnMiddlewareHookFunction>[] {
|
||||
return this.#getManager().getGlobalMiddlewareHooks();
|
||||
}
|
||||
|
||||
public registerGlobalCleanupHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerGlobalCleanupHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskCleanupHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskCleanupHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskCleanupHook(taskId: string): AnyOnCleanupHookFunction | undefined {
|
||||
return this.#getManager().getTaskCleanupHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalCleanupHooks(): RegisteredHookFunction<AnyOnCleanupHookFunction>[] {
|
||||
return this.#getManager().getGlobalCleanupHooks();
|
||||
}
|
||||
|
||||
public callOnWaitHookListeners(wait: TaskWait): Promise<void> {
|
||||
return this.#getManager().callOnWaitHookListeners(wait);
|
||||
}
|
||||
|
||||
public callOnResumeHookListeners(wait: TaskWait): Promise<void> {
|
||||
return this.#getManager().callOnResumeHookListeners(wait);
|
||||
}
|
||||
|
||||
public registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
this.#getManager().registerOnWaitHookListener(listener);
|
||||
}
|
||||
|
||||
public registerOnResumeHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
this.#getManager().registerOnResumeHookListener(listener);
|
||||
}
|
||||
|
||||
public registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {
|
||||
this.#getManager().registerGlobalCancelHook(hook);
|
||||
}
|
||||
|
||||
public registerTaskCancelHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
|
||||
): void {
|
||||
this.#getManager().registerTaskCancelHook(taskId, hook);
|
||||
}
|
||||
|
||||
public getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
|
||||
return this.#getManager().getTaskCancelHook(taskId);
|
||||
}
|
||||
|
||||
public getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
|
||||
return this.#getManager().getGlobalCancelHooks();
|
||||
}
|
||||
|
||||
public callOnCancelHookListeners(): Promise<void> {
|
||||
return this.#getManager().callOnCancelHookListeners();
|
||||
}
|
||||
|
||||
public registerOnCancelHookListener(listener: () => Promise<void>): void {
|
||||
this.#getManager().registerOnCancelHookListener(listener);
|
||||
}
|
||||
|
||||
#getManager(): LifecycleHooksManager {
|
||||
return getGlobal(API_NAME) ?? NOOP_LIFECYCLE_HOOKS_MANAGER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
import type {
|
||||
AnyOnInitHookFunction,
|
||||
AnyOnStartHookFunction,
|
||||
LifecycleHooksManager,
|
||||
RegisteredHookFunction,
|
||||
RegisterHookFunctionParams,
|
||||
AnyOnFailureHookFunction,
|
||||
AnyOnSuccessHookFunction,
|
||||
AnyOnCompleteHookFunction,
|
||||
AnyOnWaitHookFunction,
|
||||
AnyOnResumeHookFunction,
|
||||
AnyOnCatchErrorHookFunction,
|
||||
AnyOnMiddlewareHookFunction,
|
||||
AnyOnCleanupHookFunction,
|
||||
TaskWait,
|
||||
AnyOnCancelHookFunction,
|
||||
AnyOnStartAttemptHookFunction,
|
||||
} from "./types.js";
|
||||
|
||||
export class StandardLifecycleHooksManager implements LifecycleHooksManager {
|
||||
private globalInitHooks: Map<string, RegisteredHookFunction<AnyOnInitHookFunction>> = new Map();
|
||||
private taskInitHooks: Map<string, RegisteredHookFunction<AnyOnInitHookFunction>> = new Map();
|
||||
|
||||
private globalStartHooks: Map<string, RegisteredHookFunction<AnyOnStartHookFunction>> = new Map();
|
||||
private taskStartHooks: Map<string, RegisteredHookFunction<AnyOnStartHookFunction>> = new Map();
|
||||
|
||||
private globalStartAttemptHooks: Map<
|
||||
string,
|
||||
RegisteredHookFunction<AnyOnStartAttemptHookFunction>
|
||||
> = new Map();
|
||||
private taskStartAttemptHooks: Map<
|
||||
string,
|
||||
RegisteredHookFunction<AnyOnStartAttemptHookFunction>
|
||||
> = new Map();
|
||||
|
||||
private globalFailureHooks: Map<string, RegisteredHookFunction<AnyOnFailureHookFunction>> =
|
||||
new Map();
|
||||
private taskFailureHooks: Map<string, RegisteredHookFunction<AnyOnFailureHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalSuccessHooks: Map<string, RegisteredHookFunction<AnyOnSuccessHookFunction>> =
|
||||
new Map();
|
||||
private taskSuccessHooks: Map<string, RegisteredHookFunction<AnyOnSuccessHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalCompleteHooks: Map<string, RegisteredHookFunction<AnyOnCompleteHookFunction>> =
|
||||
new Map();
|
||||
private taskCompleteHooks: Map<string, RegisteredHookFunction<AnyOnCompleteHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalResumeHooks: Map<string, RegisteredHookFunction<AnyOnResumeHookFunction>> =
|
||||
new Map();
|
||||
private taskResumeHooks: Map<string, RegisteredHookFunction<AnyOnResumeHookFunction>> = new Map();
|
||||
|
||||
private globalCatchErrorHooks: Map<string, RegisteredHookFunction<AnyOnCatchErrorHookFunction>> =
|
||||
new Map();
|
||||
private taskCatchErrorHooks: Map<string, RegisteredHookFunction<AnyOnCatchErrorHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalMiddlewareHooks: Map<string, RegisteredHookFunction<AnyOnMiddlewareHookFunction>> =
|
||||
new Map();
|
||||
private taskMiddlewareHooks: Map<string, RegisteredHookFunction<AnyOnMiddlewareHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalCleanupHooks: Map<string, RegisteredHookFunction<AnyOnCleanupHookFunction>> =
|
||||
new Map();
|
||||
private taskCleanupHooks: Map<string, RegisteredHookFunction<AnyOnCleanupHookFunction>> =
|
||||
new Map();
|
||||
|
||||
private globalWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
|
||||
private taskWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
|
||||
private onWaitHookListeners: ((wait: TaskWait) => Promise<void>)[] = [];
|
||||
|
||||
private onResumeHookListeners: ((wait: TaskWait) => Promise<void>)[] = [];
|
||||
|
||||
private globalCancelHooks: Map<string, RegisteredHookFunction<AnyOnCancelHookFunction>> =
|
||||
new Map();
|
||||
private taskCancelHooks: Map<string, RegisteredHookFunction<AnyOnCancelHookFunction>> = new Map();
|
||||
private onCancelHookListeners: (() => Promise<void>)[] = [];
|
||||
|
||||
reset(): void {
|
||||
this.onCancelHookListeners.length = 0;
|
||||
this.onWaitHookListeners.length = 0;
|
||||
this.onResumeHookListeners.length = 0;
|
||||
}
|
||||
|
||||
registerOnCancelHookListener(listener: () => Promise<void>): void {
|
||||
this.onCancelHookListeners.push(listener);
|
||||
}
|
||||
|
||||
async callOnCancelHookListeners(): Promise<void> {
|
||||
await Promise.allSettled(this.onCancelHookListeners.map((listener) => listener()));
|
||||
}
|
||||
|
||||
registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
this.onWaitHookListeners.push(listener);
|
||||
}
|
||||
|
||||
async callOnWaitHookListeners(wait: TaskWait): Promise<void> {
|
||||
await Promise.allSettled(this.onWaitHookListeners.map((listener) => listener(wait)));
|
||||
}
|
||||
|
||||
registerOnResumeHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
this.onResumeHookListeners.push(listener);
|
||||
}
|
||||
|
||||
async callOnResumeHookListeners(wait: TaskWait): Promise<void> {
|
||||
await Promise.allSettled(this.onResumeHookListeners.map((listener) => listener(wait)));
|
||||
}
|
||||
|
||||
registerGlobalStartHook(hook: RegisterHookFunctionParams<AnyOnStartHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalStartHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskStartHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskStartHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskStartHook(taskId: string): AnyOnStartHookFunction | undefined {
|
||||
return this.taskStartHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalStartHooks(): RegisteredHookFunction<AnyOnStartHookFunction>[] {
|
||||
return Array.from(this.globalStartHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalStartAttemptHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
this.globalStartAttemptHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskStartAttemptHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
this.taskStartAttemptHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined {
|
||||
return this.taskStartAttemptHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalStartAttemptHooks(): RegisteredHookFunction<AnyOnStartAttemptHookFunction>[] {
|
||||
return Array.from(this.globalStartAttemptHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalInitHook(hook: RegisterHookFunctionParams<AnyOnInitHookFunction>): void {
|
||||
// if there is no id, lets generate one based on the contents of the function
|
||||
const id = generateHookId(hook);
|
||||
|
||||
const registeredHook = {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
};
|
||||
|
||||
this.globalInitHooks.set(id, registeredHook);
|
||||
}
|
||||
|
||||
registerTaskInitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnInitHookFunction>
|
||||
): void {
|
||||
const registeredHook = {
|
||||
id: generateHookId(hook),
|
||||
name: taskId,
|
||||
fn: hook.fn,
|
||||
};
|
||||
|
||||
this.taskInitHooks.set(taskId, registeredHook);
|
||||
}
|
||||
|
||||
getTaskInitHook(taskId: string): AnyOnInitHookFunction | undefined {
|
||||
return this.taskInitHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalInitHooks(): RegisteredHookFunction<AnyOnInitHookFunction>[] {
|
||||
return Array.from(this.globalInitHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalFailureHook(hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalFailureHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskFailureHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskFailureHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskFailureHook(taskId: string): AnyOnFailureHookFunction | undefined {
|
||||
return this.taskFailureHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalFailureHooks(): RegisteredHookFunction<AnyOnFailureHookFunction>[] {
|
||||
return Array.from(this.globalFailureHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalSuccessHook(hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalSuccessHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskSuccessHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskSuccessHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskSuccessHook(taskId: string): AnyOnSuccessHookFunction | undefined {
|
||||
return this.taskSuccessHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalSuccessHooks(): RegisteredHookFunction<AnyOnSuccessHookFunction>[] {
|
||||
return Array.from(this.globalSuccessHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalCompleteHook(hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalCompleteHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskCompleteHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskCompleteHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskCompleteHook(taskId: string): AnyOnCompleteHookFunction | undefined {
|
||||
return this.taskCompleteHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalCompleteHooks(): RegisteredHookFunction<AnyOnCompleteHookFunction>[] {
|
||||
return Array.from(this.globalCompleteHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalWaitHook(hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalWaitHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskWaitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskWaitHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskWaitHook(taskId: string): AnyOnWaitHookFunction | undefined {
|
||||
return this.taskWaitHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalWaitHooks(): RegisteredHookFunction<AnyOnWaitHookFunction>[] {
|
||||
return Array.from(this.globalWaitHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalResumeHook(hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalResumeHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskResumeHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskResumeHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskResumeHook(taskId: string): AnyOnResumeHookFunction | undefined {
|
||||
return this.taskResumeHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalResumeHooks(): RegisteredHookFunction<AnyOnResumeHookFunction>[] {
|
||||
return Array.from(this.globalResumeHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalCatchErrorHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalCatchErrorHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskCatchErrorHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskCatchErrorHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskCatchErrorHook(taskId: string): AnyOnCatchErrorHookFunction | undefined {
|
||||
return this.taskCatchErrorHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalCatchErrorHooks(): RegisteredHookFunction<AnyOnCatchErrorHookFunction>[] {
|
||||
return Array.from(this.globalCatchErrorHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalMiddlewareHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalMiddlewareHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskMiddlewareHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskMiddlewareHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskMiddlewareHook(taskId: string): AnyOnMiddlewareHookFunction | undefined {
|
||||
return this.taskMiddlewareHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalMiddlewareHooks(): RegisteredHookFunction<AnyOnMiddlewareHookFunction>[] {
|
||||
return Array.from(this.globalMiddlewareHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalCleanupHook(hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalCleanupHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskCleanupHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskCleanupHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getTaskCleanupHook(taskId: string): AnyOnCleanupHookFunction | undefined {
|
||||
return this.taskCleanupHooks.get(taskId)?.fn;
|
||||
}
|
||||
|
||||
getGlobalCleanupHooks(): RegisteredHookFunction<AnyOnCleanupHookFunction>[] {
|
||||
return Array.from(this.globalCleanupHooks.values());
|
||||
}
|
||||
|
||||
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.globalCancelHooks.set(id, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
registerTaskCancelHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
|
||||
): void {
|
||||
const id = generateHookId(hook);
|
||||
|
||||
this.taskCancelHooks.set(taskId, {
|
||||
id,
|
||||
name: hook.id,
|
||||
fn: hook.fn,
|
||||
});
|
||||
}
|
||||
|
||||
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
|
||||
return Array.from(this.globalCancelHooks.values());
|
||||
}
|
||||
|
||||
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
|
||||
return this.taskCancelHooks.get(taskId)?.fn;
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopLifecycleHooksManager implements LifecycleHooksManager {
|
||||
registerOnCancelHookListener(listener: () => Promise<void>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
async callOnCancelHookListeners(): Promise<void> {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {}
|
||||
|
||||
registerTaskCancelHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
async callOnWaitHookListeners(wait: TaskWait): Promise<void> {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerOnResumeHookListener(listener: (wait: TaskWait) => Promise<void>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
async callOnResumeHookListeners(wait: TaskWait): Promise<void> {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerGlobalInitHook(hook: RegisterHookFunctionParams<AnyOnInitHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskInitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnInitHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskInitHook(taskId: string): AnyOnInitHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalInitHooks(): RegisteredHookFunction<AnyOnInitHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalStartHook(hook: RegisterHookFunctionParams<AnyOnStartHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskStartHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskStartHook(taskId: string): AnyOnStartHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalStartHooks(): RegisteredHookFunction<AnyOnStartHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalStartAttemptHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskStartAttemptHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskStartAttemptHook(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalStartAttemptHooks(): RegisteredHookFunction<AnyOnStartAttemptHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalFailureHook(hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskFailureHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskFailureHook(taskId: string): AnyOnFailureHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalFailureHooks(): RegisteredHookFunction<AnyOnFailureHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalSuccessHook(hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskSuccessHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskSuccessHook(taskId: string): AnyOnSuccessHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalSuccessHooks(): RegisteredHookFunction<AnyOnSuccessHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalCompleteHook(hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskCompleteHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskCompleteHook(taskId: string): AnyOnCompleteHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalCompleteHooks(): RegisteredHookFunction<AnyOnCompleteHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalWaitHook(hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskWaitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskWaitHook(taskId: string): AnyOnWaitHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalWaitHooks(): RegisteredHookFunction<AnyOnWaitHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalResumeHook(hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskResumeHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskResumeHook(taskId: string): AnyOnResumeHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalResumeHooks(): RegisteredHookFunction<AnyOnResumeHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalCatchErrorHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskCatchErrorHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskCatchErrorHook(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalCatchErrorHooks(): [] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalMiddlewareHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskMiddlewareHook(): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskMiddlewareHook(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalMiddlewareHooks(): [] {
|
||||
return [];
|
||||
}
|
||||
|
||||
registerGlobalCleanupHook(hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
registerTaskCleanupHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>
|
||||
): void {
|
||||
// Noop
|
||||
}
|
||||
|
||||
getTaskCleanupHook(taskId: string): AnyOnCleanupHookFunction | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getGlobalCleanupHooks(): RegisteredHookFunction<AnyOnCleanupHookFunction>[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function generateHookId(hook: RegisterHookFunctionParams<any>): string {
|
||||
return hook.id ?? hook.fn.toString();
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { RetryOptions, TaskRunContext } from "../schemas/index.js";
|
||||
import type { HandleErrorResult } from "../types/index.js";
|
||||
|
||||
export type TaskInitOutput = Record<string, any> | void | undefined;
|
||||
|
||||
export type TaskInitHookParams<TPayload = unknown> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type OnInitHookFunction<TPayload, TInitOutput extends TaskInitOutput> = (
|
||||
params: TaskInitHookParams<TPayload>
|
||||
) => TInitOutput | undefined | void | Promise<TInitOutput | undefined | void>;
|
||||
|
||||
export type AnyOnInitHookFunction = OnInitHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskStartHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnStartHookFunction<TPayload, TInitOutput extends TaskInitOutput = TaskInitOutput> = (
|
||||
params: TaskStartHookParams<TPayload, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnStartHookFunction = OnStartHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskStartAttemptHookParams<TPayload = unknown> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type OnStartAttemptHookFunction<TPayload> = (
|
||||
params: TaskStartAttemptHookParams<TPayload>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnStartAttemptHookFunction = OnStartAttemptHookFunction<unknown>;
|
||||
|
||||
export type TaskWait =
|
||||
| {
|
||||
type: "duration";
|
||||
date: Date;
|
||||
}
|
||||
| {
|
||||
type: "token";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: "task";
|
||||
runId: string;
|
||||
}
|
||||
| {
|
||||
type: "batch";
|
||||
batchId: string;
|
||||
runCount: number;
|
||||
};
|
||||
|
||||
export type TaskWaitHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
wait: TaskWait;
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnWaitHookFunction<TPayload, TInitOutput extends TaskInitOutput = TaskInitOutput> = (
|
||||
params: TaskWaitHookParams<TPayload, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnWaitHookFunction = OnWaitHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskResumeHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
wait: TaskWait;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnResumeHookFunction<TPayload, TInitOutput extends TaskInitOutput = TaskInitOutput> = (
|
||||
params: TaskResumeHookParams<TPayload, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnResumeHookFunction = OnResumeHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskFailureHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
error: unknown;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnFailureHookFunction<TPayload, TInitOutput extends TaskInitOutput = TaskInitOutput> = (
|
||||
params: TaskFailureHookParams<TPayload, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnFailureHookFunction = OnFailureHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskSuccessHookParams<
|
||||
TPayload = unknown,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
output: TOutput;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnSuccessHookFunction<
|
||||
TPayload,
|
||||
TOutput,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = (
|
||||
params: TaskSuccessHookParams<TPayload, TOutput, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnSuccessHookFunction = OnSuccessHookFunction<unknown, unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskCompleteSuccessResult<TOutput> = {
|
||||
ok: true;
|
||||
data: TOutput;
|
||||
};
|
||||
|
||||
export type TaskCompleteErrorResult = {
|
||||
ok: false;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type TaskCompleteResult<TOutput> =
|
||||
| TaskCompleteSuccessResult<TOutput>
|
||||
| TaskCompleteErrorResult;
|
||||
|
||||
export type TaskCompleteHookParams<
|
||||
TPayload = unknown,
|
||||
TOutput = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
result: TaskCompleteResult<TOutput>;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnCompleteHookFunction<
|
||||
TPayload,
|
||||
TOutput,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = (
|
||||
params: TaskCompleteHookParams<TPayload, TOutput, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnCompleteHookFunction = OnCompleteHookFunction<unknown, unknown, TaskInitOutput>;
|
||||
|
||||
export type RegisterHookFunctionParams<THookFunction extends (params: any) => any> = {
|
||||
id?: string;
|
||||
fn: THookFunction;
|
||||
};
|
||||
|
||||
export type RegisteredHookFunction<THookFunction extends (params: any) => any> = {
|
||||
id: string;
|
||||
name?: string;
|
||||
fn: THookFunction;
|
||||
};
|
||||
|
||||
export type TaskCatchErrorHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
error: unknown;
|
||||
retry?: RetryOptions;
|
||||
retryAt?: Date;
|
||||
retryDelayInMs?: number;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnCatchErrorHookFunction<
|
||||
TPayload,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = (params: TaskCatchErrorHookParams<TPayload, TInitOutput>) => HandleErrorResult;
|
||||
|
||||
export type AnyOnCatchErrorHookFunction = OnCatchErrorHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskMiddlewareHookParams<TPayload = unknown> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
next: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type OnMiddlewareHookFunction<TPayload> = (
|
||||
params: TaskMiddlewareHookParams<TPayload>
|
||||
) => Promise<void>;
|
||||
|
||||
export type AnyOnMiddlewareHookFunction = OnMiddlewareHookFunction<unknown>;
|
||||
|
||||
export type TaskCleanupHookParams<
|
||||
TPayload = unknown,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
signal: AbortSignal;
|
||||
init?: TInitOutput;
|
||||
};
|
||||
|
||||
export type OnCleanupHookFunction<TPayload, TInitOutput extends TaskInitOutput = TaskInitOutput> = (
|
||||
params: TaskCleanupHookParams<TPayload, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnCleanupHookFunction = OnCleanupHookFunction<unknown, TaskInitOutput>;
|
||||
|
||||
export type TaskCancelHookParams<
|
||||
TPayload = unknown,
|
||||
TRunOutput = any,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = {
|
||||
ctx: TaskRunContext;
|
||||
payload: TPayload;
|
||||
task: string;
|
||||
runPromise: Promise<TRunOutput>;
|
||||
init?: TInitOutput;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type OnCancelHookFunction<
|
||||
TPayload,
|
||||
TRunOutput = any,
|
||||
TInitOutput extends TaskInitOutput = TaskInitOutput,
|
||||
> = (
|
||||
params: TaskCancelHookParams<TPayload, TRunOutput, TInitOutput>
|
||||
) => undefined | void | Promise<undefined | void>;
|
||||
|
||||
export type AnyOnCancelHookFunction = OnCancelHookFunction<unknown, unknown, TaskInitOutput>;
|
||||
|
||||
export interface LifecycleHooksManager {
|
||||
registerGlobalInitHook(hook: RegisterHookFunctionParams<AnyOnInitHookFunction>): void;
|
||||
registerTaskInitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnInitHookFunction>
|
||||
): void;
|
||||
getTaskInitHook(taskId: string): AnyOnInitHookFunction | undefined;
|
||||
getGlobalInitHooks(): RegisteredHookFunction<AnyOnInitHookFunction>[];
|
||||
registerGlobalStartHook(hook: RegisterHookFunctionParams<AnyOnStartHookFunction>): void;
|
||||
registerTaskStartHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartHookFunction>
|
||||
): void;
|
||||
getTaskStartHook(taskId: string): AnyOnStartHookFunction | undefined;
|
||||
getGlobalStartHooks(): RegisteredHookFunction<AnyOnStartHookFunction>[];
|
||||
|
||||
registerGlobalStartAttemptHook(
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void;
|
||||
registerTaskStartAttemptHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnStartAttemptHookFunction>
|
||||
): void;
|
||||
getTaskStartAttemptHook(taskId: string): AnyOnStartAttemptHookFunction | undefined;
|
||||
getGlobalStartAttemptHooks(): RegisteredHookFunction<AnyOnStartAttemptHookFunction>[];
|
||||
|
||||
registerGlobalFailureHook(hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>): void;
|
||||
registerTaskFailureHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnFailureHookFunction>
|
||||
): void;
|
||||
getTaskFailureHook(taskId: string): AnyOnFailureHookFunction | undefined;
|
||||
getGlobalFailureHooks(): RegisteredHookFunction<AnyOnFailureHookFunction>[];
|
||||
registerGlobalSuccessHook(hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>): void;
|
||||
registerTaskSuccessHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnSuccessHookFunction>
|
||||
): void;
|
||||
getTaskSuccessHook(taskId: string): AnyOnSuccessHookFunction | undefined;
|
||||
getGlobalSuccessHooks(): RegisteredHookFunction<AnyOnSuccessHookFunction>[];
|
||||
registerGlobalCompleteHook(hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>): void;
|
||||
registerTaskCompleteHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCompleteHookFunction>
|
||||
): void;
|
||||
getTaskCompleteHook(taskId: string): AnyOnCompleteHookFunction | undefined;
|
||||
getGlobalCompleteHooks(): RegisteredHookFunction<AnyOnCompleteHookFunction>[];
|
||||
registerGlobalWaitHook(hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>): void;
|
||||
registerTaskWaitHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnWaitHookFunction>
|
||||
): void;
|
||||
getTaskWaitHook(taskId: string): AnyOnWaitHookFunction | undefined;
|
||||
getGlobalWaitHooks(): RegisteredHookFunction<AnyOnWaitHookFunction>[];
|
||||
registerGlobalResumeHook(hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>): void;
|
||||
registerTaskResumeHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnResumeHookFunction>
|
||||
): void;
|
||||
getTaskResumeHook(taskId: string): AnyOnResumeHookFunction | undefined;
|
||||
getGlobalResumeHooks(): RegisteredHookFunction<AnyOnResumeHookFunction>[];
|
||||
registerGlobalCatchErrorHook(hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>): void;
|
||||
registerTaskCatchErrorHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCatchErrorHookFunction>
|
||||
): void;
|
||||
getTaskCatchErrorHook(taskId: string): AnyOnCatchErrorHookFunction | undefined;
|
||||
getGlobalCatchErrorHooks(): RegisteredHookFunction<AnyOnCatchErrorHookFunction>[];
|
||||
registerGlobalMiddlewareHook(hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>): void;
|
||||
registerTaskMiddlewareHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnMiddlewareHookFunction>
|
||||
): void;
|
||||
getTaskMiddlewareHook(taskId: string): AnyOnMiddlewareHookFunction | undefined;
|
||||
getGlobalMiddlewareHooks(): RegisteredHookFunction<AnyOnMiddlewareHookFunction>[];
|
||||
registerGlobalCleanupHook(hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>): void;
|
||||
registerTaskCleanupHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCleanupHookFunction>
|
||||
): void;
|
||||
getTaskCleanupHook(taskId: string): AnyOnCleanupHookFunction | undefined;
|
||||
getGlobalCleanupHooks(): RegisteredHookFunction<AnyOnCleanupHookFunction>[];
|
||||
|
||||
callOnWaitHookListeners(wait: TaskWait): Promise<void>;
|
||||
registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void;
|
||||
|
||||
callOnResumeHookListeners(wait: TaskWait): Promise<void>;
|
||||
registerOnResumeHookListener(listener: (wait: TaskWait) => Promise<void>): void;
|
||||
|
||||
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void;
|
||||
registerTaskCancelHook(
|
||||
taskId: string,
|
||||
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
|
||||
): void;
|
||||
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[];
|
||||
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined;
|
||||
|
||||
registerOnCancelHookListener(listener: () => Promise<void>): void;
|
||||
callOnCancelHookListeners(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AttributeValue, Attributes } from "@opentelemetry/api";
|
||||
import { getEnvVar } from "./utils/getEnv.js";
|
||||
|
||||
function getOtelEnvVarLimit(key: string, defaultValue: number) {
|
||||
const value = getEnvVar(key);
|
||||
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
export const OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT",
|
||||
1024
|
||||
);
|
||||
export const OTEL_LOG_ATTRIBUTE_COUNT_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT",
|
||||
1024
|
||||
);
|
||||
export const OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT",
|
||||
131072
|
||||
);
|
||||
export const OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT",
|
||||
131072
|
||||
);
|
||||
export const OTEL_SPAN_EVENT_COUNT_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT",
|
||||
10
|
||||
);
|
||||
export const OTEL_LINK_COUNT_LIMIT = getOtelEnvVarLimit("TRIGGER_OTEL_LINK_COUNT_LIMIT", 2);
|
||||
export const OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT",
|
||||
10
|
||||
);
|
||||
export const OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT = getOtelEnvVarLimit(
|
||||
"TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT",
|
||||
10
|
||||
);
|
||||
|
||||
export const OFFLOAD_IO_PACKET_LENGTH_LIMIT = 128 * 1024;
|
||||
|
||||
export function imposeAttributeLimits(attributes: Attributes): Attributes {
|
||||
const newAttributes: Attributes = {};
|
||||
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (calculateAttributeValueLength(value) > OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newAttributes[key] = value;
|
||||
}
|
||||
|
||||
return newAttributes;
|
||||
}
|
||||
|
||||
function calculateAttributeValueLength(value: AttributeValue | undefined | null): number {
|
||||
if (value === undefined || value === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.length;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce((acc: number, v) => acc + calculateAttributeValueLength(v), 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const links = {
|
||||
docs: {
|
||||
config: {
|
||||
home: "https://trigger.dev/docs/config/config-file",
|
||||
additionalPackages: "https://trigger.dev/docs/config/config-file#additionalpackages",
|
||||
extensions: "https://trigger.dev/docs/config/config-file#extensions",
|
||||
prisma: "https://trigger.dev/docs/config/config-file#prisma",
|
||||
},
|
||||
machines: {
|
||||
home: "https://trigger.dev/docs/v3/machines",
|
||||
},
|
||||
upgrade: {
|
||||
beta: "https://trigger.dev/docs/upgrading-beta",
|
||||
},
|
||||
troubleshooting: {
|
||||
concurrentWaits: "https://trigger.dev/docs/troubleshooting#parallel-waits-are-not-supported",
|
||||
stalledExecution: "https://trigger.dev/docs/troubleshooting#task-run-stalled-executing",
|
||||
uncaughtException: "https://trigger.dev/docs/troubleshooting#uncaught-exceptions",
|
||||
},
|
||||
concurrency: {
|
||||
recursiveDeadlock:
|
||||
"https://trigger.dev/docs/queue-concurrency#waiting-for-a-subtask-on-the-same-queue",
|
||||
deadlock: "https://trigger.dev/docs/queue-concurrency#deadlock",
|
||||
},
|
||||
gitHubActions: {
|
||||
personalAccessToken:
|
||||
"https://trigger.dev/docs/github-actions#creating-a-personal-access-token",
|
||||
},
|
||||
},
|
||||
site: {
|
||||
home: "https://trigger.dev",
|
||||
contact: "https://trigger.dev/contact",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { LocalsAPI } from "./locals/index.js";
|
||||
import type { LocalsKey } from "./locals/types.js";
|
||||
/** Entrypoint for runtime API */
|
||||
export const localsAPI = LocalsAPI.getInstance();
|
||||
|
||||
export const locals = {
|
||||
create<T>(id: string): LocalsKey<T> {
|
||||
return localsAPI.createLocal(id);
|
||||
},
|
||||
get<T>(key: LocalsKey<T>): T | undefined {
|
||||
return localsAPI.getLocal(key);
|
||||
},
|
||||
getOrThrow<T>(key: LocalsKey<T>): T {
|
||||
const value = localsAPI.getLocal(key);
|
||||
if (!value) {
|
||||
throw new Error(`Local with id ${key.id} not found`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set<T>(key: LocalsKey<T>, value: T): T {
|
||||
localsAPI.setLocal(key, value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
export type Locals = typeof locals;
|
||||
export type { LocalsKey };
|
||||
@@ -0,0 +1,45 @@
|
||||
const API_NAME = "locals";
|
||||
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import { NoopLocalsManager } from "./manager.js";
|
||||
import { type LocalsKey, type LocalsManager } from "./types.js";
|
||||
|
||||
const NOOP_LOCALS_MANAGER = new NoopLocalsManager();
|
||||
|
||||
export class LocalsAPI implements LocalsManager {
|
||||
private static _instance?: LocalsAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LocalsAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new LocalsAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public setGlobalLocalsManager(localsManager: LocalsManager): boolean {
|
||||
return registerGlobal(API_NAME, localsManager);
|
||||
}
|
||||
|
||||
public disable() {
|
||||
unregisterGlobal(API_NAME);
|
||||
}
|
||||
|
||||
public createLocal<T>(id: string): LocalsKey<T> {
|
||||
return this.#getManager().createLocal(id);
|
||||
}
|
||||
|
||||
public getLocal<T>(key: LocalsKey<T>): T | undefined {
|
||||
return this.#getManager().getLocal(key);
|
||||
}
|
||||
|
||||
public setLocal<T>(key: LocalsKey<T>, value: T): void {
|
||||
return this.#getManager().setLocal(key, value);
|
||||
}
|
||||
|
||||
#getManager(): LocalsManager {
|
||||
return getGlobal(API_NAME) ?? NOOP_LOCALS_MANAGER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { LocalsKey, LocalsManager } from "./types.js";
|
||||
|
||||
export class NoopLocalsManager implements LocalsManager {
|
||||
createLocal<T>(id: string): LocalsKey<T> {
|
||||
return {
|
||||
__type: Symbol(),
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
getLocal<T>(key: LocalsKey<T>): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setLocal<T>(key: LocalsKey<T>, value: T): void {}
|
||||
}
|
||||
|
||||
export class StandardLocalsManager implements LocalsManager {
|
||||
private store: Map<symbol, unknown> = new Map();
|
||||
|
||||
createLocal<T>(id: string): LocalsKey<T> {
|
||||
const key = Symbol.for(id);
|
||||
return {
|
||||
__type: key,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
getLocal<T>(key: LocalsKey<T>): T | undefined {
|
||||
return this.store.get(key.__type) as T | undefined;
|
||||
}
|
||||
|
||||
setLocal<T>(key: LocalsKey<T>, value: T): void {
|
||||
this.store.set(key.__type, value);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A type-safe key for `locals`. Carries the value type `T` as a phantom
|
||||
* marker on the optional `__valueType` field so two keys with different
|
||||
* value types are distinguishable at the type level.
|
||||
*
|
||||
* The phantom field is intentionally not anchored to a `unique symbol`:
|
||||
* dual-package builds (`tshy`) emit separate `.d.ts` files for ESM and
|
||||
* CJS outputs, and each `unique symbol` declaration in a `.d.ts` is its
|
||||
* own nominal type. If a single compilation ever resolves `LocalsKey`
|
||||
* from both the ESM and CJS paths — which happens under certain pnpm
|
||||
* hoisting layouts — `unique symbol` brands produce structurally
|
||||
* incompatible variants of the same type. A plain string brand avoids
|
||||
* the hazard.
|
||||
*/
|
||||
export type LocalsKey<T> = {
|
||||
readonly id: string;
|
||||
readonly __type: symbol;
|
||||
/** Phantom carrier for the value type — never read at runtime. */
|
||||
readonly __valueType?: T;
|
||||
};
|
||||
|
||||
export interface LocalsManager {
|
||||
createLocal<T>(id: string): LocalsKey<T>;
|
||||
getLocal<T>(key: LocalsKey<T>): T | undefined;
|
||||
setLocal<T>(key: LocalsKey<T>, value: T): void;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { LoggerAPI } from "./logger/index.js";
|
||||
/** Entrypoint for logger API */
|
||||
export const logger = LoggerAPI.getInstance();
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TaskLogger, TraceOptions } from "./taskLogger.js";
|
||||
import { NoopTaskLogger } from "./taskLogger.js";
|
||||
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
|
||||
import type { Span } from "@opentelemetry/api";
|
||||
|
||||
const API_NAME = "logger";
|
||||
|
||||
const NOOP_TASK_LOGGER = new NoopTaskLogger();
|
||||
|
||||
export class LoggerAPI implements TaskLogger {
|
||||
private static _instance?: LoggerAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LoggerAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new LoggerAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
public disable() {
|
||||
unregisterGlobal(API_NAME);
|
||||
}
|
||||
|
||||
public setGlobalTaskLogger(taskLogger: TaskLogger): boolean {
|
||||
return registerGlobal(API_NAME, taskLogger);
|
||||
}
|
||||
|
||||
public debug(message: string, metadata?: Record<string, unknown>) {
|
||||
this.#getTaskLogger().debug(message, metadata);
|
||||
}
|
||||
|
||||
public log(message: string, metadata?: Record<string, unknown>) {
|
||||
this.#getTaskLogger().log(message, metadata);
|
||||
}
|
||||
|
||||
public info(message: string, metadata?: Record<string, unknown>) {
|
||||
this.#getTaskLogger().info(message, metadata);
|
||||
}
|
||||
|
||||
public warn(message: string, metadata?: Record<string, unknown>) {
|
||||
this.#getTaskLogger().warn(message, metadata);
|
||||
}
|
||||
|
||||
public error(message: string, metadata?: Record<string, unknown>) {
|
||||
this.#getTaskLogger().error(message, metadata);
|
||||
}
|
||||
|
||||
public trace<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
options?: TraceOptions
|
||||
): Promise<T> {
|
||||
return this.#getTaskLogger().trace(name, fn, options);
|
||||
}
|
||||
|
||||
public startSpan(name: string, options?: TraceOptions): Span {
|
||||
return this.#getTaskLogger().startSpan(name, options);
|
||||
}
|
||||
|
||||
#getTaskLogger(): TaskLogger {
|
||||
return getGlobal(API_NAME) ?? NOOP_TASK_LOGGER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Attributes, Span, SpanOptions } from "@opentelemetry/api";
|
||||
import type { Logger } from "@opentelemetry/api-logs";
|
||||
import { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { iconStringForSeverity } from "../icons.js";
|
||||
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
|
||||
import type { TriggerTracer } from "../tracer.js";
|
||||
import { flattenAttributes } from "../utils/flattenAttributes.js";
|
||||
import type { ClockTime } from "../clock/clock.js";
|
||||
import { clock } from "../clock-api.js";
|
||||
import type { Prettify } from "../types/utils.js";
|
||||
|
||||
export type LogLevel = "none" | "error" | "warn" | "info" | "debug" | "log";
|
||||
|
||||
export const logLevels: Array<LogLevel> = ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
export type TaskLoggerConfig = {
|
||||
logger: Logger;
|
||||
tracer: TriggerTracer;
|
||||
level: LogLevel;
|
||||
maxAttributeCount?: number;
|
||||
};
|
||||
|
||||
export type TraceOptions = Prettify<
|
||||
SpanOptions & {
|
||||
icon?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface TaskLogger {
|
||||
debug(message: string, properties?: Record<string, unknown>): void;
|
||||
log(message: string, properties?: Record<string, unknown>): void;
|
||||
info(message: string, properties?: Record<string, unknown>): void;
|
||||
warn(message: string, properties?: Record<string, unknown>): void;
|
||||
error(message: string, properties?: Record<string, unknown>): void;
|
||||
trace<T>(name: string, fn: (span: Span) => Promise<T>, options?: TraceOptions): Promise<T>;
|
||||
startSpan(name: string, options?: TraceOptions): Span;
|
||||
}
|
||||
|
||||
export class OtelTaskLogger implements TaskLogger {
|
||||
private readonly _level: number;
|
||||
|
||||
constructor(private readonly _config: TaskLoggerConfig) {
|
||||
this._level = logLevels.indexOf(_config.level);
|
||||
}
|
||||
|
||||
debug(message: string, properties?: Record<string, unknown>) {
|
||||
if (this._level < 4) return; // ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
this.#emitLog(message, this.#getTimestampInHrTime(), "debug", SeverityNumber.DEBUG, properties);
|
||||
}
|
||||
|
||||
log(message: string, properties?: Record<string, unknown>) {
|
||||
if (this._level < 3) return; // ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
this.#emitLog(message, this.#getTimestampInHrTime(), "log", SeverityNumber.INFO, properties);
|
||||
}
|
||||
|
||||
info(message: string, properties?: Record<string, unknown>) {
|
||||
if (this._level < 3) return; // ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
this.#emitLog(message, this.#getTimestampInHrTime(), "info", SeverityNumber.INFO, properties);
|
||||
}
|
||||
|
||||
warn(message: string, properties?: Record<string, unknown>) {
|
||||
if (this._level < 2) return; // ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
this.#emitLog(message, this.#getTimestampInHrTime(), "warn", SeverityNumber.WARN, properties);
|
||||
}
|
||||
|
||||
error(message: string, properties?: Record<string, unknown>) {
|
||||
if (this._level < 1) return; // ["none", "error", "warn", "info", "debug"];
|
||||
|
||||
this.#emitLog(message, this.#getTimestampInHrTime(), "error", SeverityNumber.ERROR, properties);
|
||||
}
|
||||
|
||||
#emitLog(
|
||||
message: string,
|
||||
timestamp: ClockTime,
|
||||
severityText: string,
|
||||
severityNumber: SeverityNumber,
|
||||
properties?: Record<string, unknown>
|
||||
) {
|
||||
let attributes: Attributes = {};
|
||||
|
||||
if (properties) {
|
||||
// Use flattenAttributes directly - it now handles all non-JSON friendly values efficiently
|
||||
attributes = flattenAttributes(properties, undefined, this._config.maxAttributeCount);
|
||||
}
|
||||
|
||||
const icon = iconStringForSeverity(severityNumber);
|
||||
if (icon !== undefined) {
|
||||
attributes[SemanticInternalAttributes.STYLE_ICON] = icon;
|
||||
}
|
||||
|
||||
this._config.logger.emit({
|
||||
severityNumber,
|
||||
severityText,
|
||||
body: message,
|
||||
attributes,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
trace<T>(name: string, fn: (span: Span) => Promise<T>, options?: TraceOptions): Promise<T> {
|
||||
const spanOptions = {
|
||||
...options,
|
||||
attributes: {
|
||||
...options?.attributes,
|
||||
[SemanticInternalAttributes.STYLE_ICON]: options?.icon ?? "trace",
|
||||
},
|
||||
};
|
||||
|
||||
return this._config.tracer.startActiveSpan(name, fn, spanOptions);
|
||||
}
|
||||
|
||||
startSpan(name: string, options?: TraceOptions): Span {
|
||||
const spanOptions = {
|
||||
...options,
|
||||
attributes: {
|
||||
...options?.attributes,
|
||||
...(options?.icon ? { [SemanticInternalAttributes.STYLE_ICON]: options.icon } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
return this._config.tracer.startSpan(name, spanOptions);
|
||||
}
|
||||
|
||||
#getTimestampInHrTime(): ClockTime {
|
||||
return clock.preciseNow();
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopTaskLogger implements TaskLogger {
|
||||
debug() {}
|
||||
log() {}
|
||||
info() {}
|
||||
warn() {}
|
||||
error() {}
|
||||
trace<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
return fn({} as Span);
|
||||
}
|
||||
startSpan(): Span {
|
||||
return {} as Span;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { MachinePresetResources } from "../schemas/common.js";
|
||||
|
||||
/**
|
||||
* Returns a value to be used for `--max-old-space-size`. It is in MiB.
|
||||
* Setting this correctly means V8 spends more times running Garbage Collection (GC).
|
||||
* It won't eliminate crashes but it will help avoid them.
|
||||
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
|
||||
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
|
||||
* @returns {number} The calculated max old space size in MiB
|
||||
*/
|
||||
export function maxOldSpaceSizeForMachine(
|
||||
machine: MachinePresetResources,
|
||||
overhead: number = 0.2
|
||||
): number {
|
||||
return Math.round(machine.memory * 1_024 * (1 - overhead));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flag to be used for `--max-old-space-size`. It is in MiB.
|
||||
* Setting this correctly means V8 spends more times running Garbage Collection (GC).
|
||||
* It won't eliminate crashes but it will help avoid them.
|
||||
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
|
||||
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
|
||||
* @returns {string} The calculated max old space size flag
|
||||
*/
|
||||
export function maxOldSpaceSizeFlag(
|
||||
machine: MachinePresetResources,
|
||||
overhead: number = 0.2
|
||||
): string {
|
||||
return `--max-old-space-size=${maxOldSpaceSizeForMachine(machine, overhead)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the existing NODE_OPTIONS value, removes any existing max-old-space-size flag, and adds a new one.
|
||||
* @param {string | undefined} existingOptions - The existing NODE_OPTIONS value
|
||||
* @param {MachinePresetResources} machine - The machine preset configuration containing memory specifications
|
||||
* @param {number} [overhead=0.2] - The memory overhead factor (0.2 = 20% reserved for system operations)
|
||||
* @returns {string} The updated NODE_OPTIONS value with the new max-old-space-size flag
|
||||
*/
|
||||
export function nodeOptionsWithMaxOldSpaceSize(
|
||||
existingOptions: string | undefined,
|
||||
machine: MachinePresetResources,
|
||||
overhead: number = 0.2
|
||||
): string {
|
||||
let options = existingOptions ?? "";
|
||||
|
||||
//remove existing max-old-space-size flag
|
||||
options = options.replace(/--max-old-space-size=\d+/g, "").trim();
|
||||
|
||||
//get max-old-space-size flag
|
||||
const flag = maxOldSpaceSizeFlag(machine, overhead);
|
||||
|
||||
return normalizeCommandLineFlags(options ? `${options} ${flag}` : flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes spaces in a string of command line flags, ensuring single spaces between flags
|
||||
* @param {string} input - The string to normalize
|
||||
* @returns {string} The normalized string with single spaces between flags
|
||||
*/
|
||||
function normalizeCommandLineFlags(input: string): string {
|
||||
return input.split(/\s+/).filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nodeOptionsWithMaxOldSpaceSize } from "./index.js";
|
||||
import type { MachinePreset } from "../schemas/common.js";
|
||||
|
||||
describe("nodeOptionsWithMaxOldSpaceSize", () => {
|
||||
const testMachine: MachinePreset = {
|
||||
name: "small-2x",
|
||||
memory: 1, // 1GB = 1024 MiB
|
||||
cpu: 1,
|
||||
centsPerMs: 0,
|
||||
};
|
||||
|
||||
// With default 0.2 overhead, max-old-space-size should be 819 (1024 * 0.8)
|
||||
const expectedFlag = "--max-old-space-size=819";
|
||||
|
||||
it("handles undefined NODE_OPTIONS", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize(undefined, testMachine);
|
||||
expect(result).toBe(expectedFlag);
|
||||
});
|
||||
|
||||
it("handles empty string NODE_OPTIONS", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize("", testMachine);
|
||||
expect(result).toBe(expectedFlag);
|
||||
});
|
||||
|
||||
it("preserves existing flags while adding max-old-space-size", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize("--inspect --trace-warnings", testMachine);
|
||||
expect(result).toBe(`--inspect --trace-warnings ${expectedFlag}`);
|
||||
});
|
||||
|
||||
it("replaces existing max-old-space-size flag", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize(
|
||||
"--max-old-space-size=4096 --inspect",
|
||||
testMachine
|
||||
);
|
||||
expect(result).toBe(`--inspect ${expectedFlag}`);
|
||||
});
|
||||
|
||||
it("handles multiple existing max-old-space-size flags", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize(
|
||||
"--max-old-space-size=4096 --inspect --max-old-space-size=8192",
|
||||
testMachine
|
||||
);
|
||||
expect(result).toBe(`--inspect ${expectedFlag}`);
|
||||
});
|
||||
|
||||
it("handles extra spaces between flags", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize("--inspect --trace-warnings", testMachine);
|
||||
expect(result).toBe(`--inspect --trace-warnings ${expectedFlag}`);
|
||||
});
|
||||
|
||||
it("uses custom overhead value", () => {
|
||||
const result = nodeOptionsWithMaxOldSpaceSize("--inspect", testMachine, 0.5);
|
||||
// With 0.5 overhead, max-old-space-size should be 512 (1024 * 0.5)
|
||||
expect(result).toBe("--inspect --max-old-space-size=512");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsPromises from "node:fs/promises";
|
||||
|
||||
const SECTOR_SIZE = 512;
|
||||
|
||||
const FILTERED_DEVICE_PREFIXES = ["loop", "ram", "dm-"];
|
||||
|
||||
type DiskStats = {
|
||||
device: string;
|
||||
readsCompleted: number;
|
||||
sectorsRead: number;
|
||||
writesCompleted: number;
|
||||
sectorsWritten: number;
|
||||
};
|
||||
|
||||
function parseProcDiskstats(content: string): DiskStats[] {
|
||||
const entries: DiskStats[] = [];
|
||||
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const fields = trimmed.split(/\s+/);
|
||||
if (fields.length < 14) continue;
|
||||
|
||||
const device = fields[2]!;
|
||||
|
||||
if (FILTERED_DEVICE_PREFIXES.some((prefix) => device.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
device,
|
||||
readsCompleted: parseInt(fields[3]!, 10),
|
||||
sectorsRead: parseInt(fields[5]!, 10),
|
||||
writesCompleted: parseInt(fields[7]!, 10),
|
||||
sectorsWritten: parseInt(fields[9]!, 10),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function startDiskIoMetrics(meterProvider: MeterProvider) {
|
||||
try {
|
||||
fs.accessSync("/proc/diskstats", fs.constants.R_OK);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const meter = meterProvider.getMeter("system-disk", "1.0.0");
|
||||
|
||||
const ioCounter = meter.createObservableCounter("system.disk.io", {
|
||||
description: "Disk I/O bytes read and written per device",
|
||||
unit: "By",
|
||||
});
|
||||
|
||||
const opsCounter = meter.createObservableCounter("system.disk.operations", {
|
||||
description: "Disk read/write operation counts per device",
|
||||
unit: "{operation}",
|
||||
});
|
||||
|
||||
meter.addBatchObservableCallback(
|
||||
async (obs) => {
|
||||
try {
|
||||
const content = await fsPromises.readFile("/proc/diskstats", "utf-8");
|
||||
const stats = parseProcDiskstats(content);
|
||||
|
||||
for (const entry of stats) {
|
||||
const readAttrs = {
|
||||
"system.device": entry.device,
|
||||
"disk.io.direction": "read",
|
||||
};
|
||||
const writeAttrs = {
|
||||
"system.device": entry.device,
|
||||
"disk.io.direction": "write",
|
||||
};
|
||||
|
||||
obs.observe(ioCounter, entry.sectorsRead * SECTOR_SIZE, readAttrs);
|
||||
obs.observe(ioCounter, entry.sectorsWritten * SECTOR_SIZE, writeAttrs);
|
||||
|
||||
obs.observe(opsCounter, entry.readsCompleted, readAttrs);
|
||||
obs.observe(opsCounter, entry.writesCompleted, writeAttrs);
|
||||
}
|
||||
} catch {
|
||||
// Skip entire cycle on failure
|
||||
}
|
||||
},
|
||||
[ioCounter, opsCounter]
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user