effect-inngest 0.1.3 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +89 -67
  2. package/dist/Client.d.ts +76 -28
  3. package/dist/Client.js +92 -30
  4. package/dist/Event.d.ts +43 -0
  5. package/dist/Event.js +46 -0
  6. package/dist/Events.d.ts +48 -76
  7. package/dist/Events.js +18 -23
  8. package/dist/Function.d.ts +46 -19
  9. package/dist/Function.js +32 -22
  10. package/dist/Group.d.ts +19 -8
  11. package/dist/Group.js +30 -72
  12. package/dist/HttpApi.d.ts +51 -56
  13. package/dist/HttpApi.js +38 -21
  14. package/dist/_virtual/_rolldown/runtime.js +13 -0
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +3 -3
  17. package/dist/internal/checkpoint/Config.d.ts +10 -0
  18. package/dist/internal/checkpoint/Config.js +29 -0
  19. package/dist/internal/checkpoint/Error.d.ts +11 -0
  20. package/dist/internal/checkpoint/Error.js +8 -0
  21. package/dist/internal/checkpoint/State.d.ts +1 -0
  22. package/dist/internal/checkpoint/State.js +54 -0
  23. package/dist/internal/checkpoint.d.ts +2 -0
  24. package/dist/internal/codec/EventPayload.d.ts +6 -0
  25. package/dist/internal/codec/EventPayload.js +60 -0
  26. package/dist/internal/codec/StepResult.js +55 -0
  27. package/dist/internal/domain/ExecutionInput.d.ts +40 -0
  28. package/dist/internal/domain/ExecutionInput.js +57 -0
  29. package/dist/internal/domain/ExecutionSuspension.d.ts +34 -0
  30. package/dist/internal/domain/ExecutionSuspension.js +64 -0
  31. package/dist/internal/domain/FunctionDefinition.js +13 -0
  32. package/dist/internal/domain/Memo.d.ts +22 -0
  33. package/dist/internal/domain/Memo.js +18 -0
  34. package/dist/internal/domain/StepCommand.d.ts +79 -0
  35. package/dist/internal/domain/StepCommand.js +124 -0
  36. package/dist/internal/domain/StepInfo.d.ts +12 -0
  37. package/dist/internal/domain/StepInfo.js +10 -0
  38. package/dist/internal/domain/StepInput.d.ts +10 -0
  39. package/dist/internal/driver.js +15 -114
  40. package/dist/internal/errors.d.ts +21 -48
  41. package/dist/internal/errors.js +9 -44
  42. package/dist/internal/execution/CheckpointRun.js +25 -0
  43. package/dist/internal/execution/ExecutionFailure.js +19 -0
  44. package/dist/internal/execution/ExecutionHeaders.js +58 -0
  45. package/dist/internal/execution/ExecutionResponse.js +68 -0
  46. package/dist/internal/execution/ExecutionResult.js +56 -0
  47. package/dist/internal/execution/ExecutionScope.js +30 -0
  48. package/dist/internal/execution/HandlerRun.js +16 -0
  49. package/dist/internal/handler.d.ts +5 -16
  50. package/dist/internal/handler.js +128 -96
  51. package/dist/internal/protocol.d.ts +143 -1
  52. package/dist/internal/protocol.js +333 -138
  53. package/dist/internal/runtime/CheckpointContext.js +5 -0
  54. package/dist/internal/runtime/HandlerContext.d.ts +13 -0
  55. package/dist/internal/runtime/HandlerContext.js +19 -0
  56. package/dist/internal/runtime/HandlerFiberScope.d.ts +10 -0
  57. package/dist/internal/runtime/HandlerFiberScope.js +5 -0
  58. package/dist/internal/runtime/StepCommandBus.d.ts +27 -0
  59. package/dist/internal/runtime/StepCommandBus.js +76 -0
  60. package/dist/internal/runtime/StepIdentity.d.ts +27 -0
  61. package/dist/internal/runtime/StepIdentity.js +46 -0
  62. package/dist/internal/runtime/StepTools.d.ts +83 -0
  63. package/dist/internal/runtime/StepTools.js +76 -0
  64. package/dist/internal/runtime/steps/InvokeStep.js +43 -0
  65. package/dist/internal/runtime/steps/SendEventStep.js +46 -0
  66. package/dist/internal/runtime/steps/SleepStep.js +22 -0
  67. package/dist/internal/runtime/steps/SleepUntilStep.js +22 -0
  68. package/dist/internal/runtime/steps/StepRun.js +48 -0
  69. package/dist/internal/runtime/steps/WaitForEventStep.js +27 -0
  70. package/dist/internal/serve/HttpApp.js +71 -0
  71. package/dist/internal/serve/Request.js +23 -0
  72. package/dist/internal/serve/Signature.d.ts +11 -0
  73. package/dist/internal/serve/Signature.js +123 -0
  74. package/dist/internal/wire/Duration.js +19 -0
  75. package/dist/internal/wire/Timestamp.js +14 -0
  76. package/package.json +34 -22
  77. package/src/Client.ts +269 -91
  78. package/src/Event.ts +107 -0
  79. package/src/Events.ts +50 -30
  80. package/src/Function.ts +102 -46
  81. package/src/Group.ts +56 -108
  82. package/src/HttpApi.ts +40 -30
  83. package/src/index.ts +21 -11
  84. package/src/internal/checkpoint/Config.ts +74 -0
  85. package/src/internal/checkpoint/Error.ts +6 -0
  86. package/src/internal/checkpoint/State.ts +107 -0
  87. package/src/internal/checkpoint.ts +3 -0
  88. package/src/internal/codec/EventPayload.ts +98 -0
  89. package/src/internal/codec/StepResult.ts +95 -0
  90. package/src/internal/domain/ExecutionInput.ts +66 -0
  91. package/src/internal/domain/ExecutionSuspension.ts +79 -0
  92. package/src/internal/domain/FunctionDefinition.ts +30 -0
  93. package/src/internal/domain/Memo.ts +28 -0
  94. package/src/internal/domain/StepCommand.ts +166 -0
  95. package/src/internal/domain/StepInfo.ts +8 -0
  96. package/src/internal/domain/StepInput.ts +10 -0
  97. package/src/internal/driver.ts +27 -185
  98. package/src/internal/errors.ts +14 -108
  99. package/src/internal/execution/CheckpointRun.ts +33 -0
  100. package/src/internal/execution/ExecutionFailure.ts +19 -0
  101. package/src/internal/execution/ExecutionHeaders.ts +86 -0
  102. package/src/internal/execution/ExecutionResponse.ts +79 -0
  103. package/src/internal/execution/ExecutionResult.ts +57 -0
  104. package/src/internal/execution/ExecutionScope.ts +41 -0
  105. package/src/internal/execution/HandlerRun.ts +38 -0
  106. package/src/internal/handler.ts +222 -172
  107. package/src/internal/protocol.ts +289 -78
  108. package/src/internal/runtime/CheckpointContext.ts +7 -0
  109. package/src/internal/runtime/HandlerContext.ts +21 -0
  110. package/src/internal/runtime/HandlerFiberScope.ts +9 -0
  111. package/src/internal/runtime/StepCommandBus.ts +129 -0
  112. package/src/internal/runtime/StepIdentity.ts +67 -0
  113. package/src/internal/runtime/StepTools.ts +161 -0
  114. package/src/internal/runtime/steps/InvokeStep.ts +71 -0
  115. package/src/internal/runtime/steps/SendEventStep.ts +67 -0
  116. package/src/internal/runtime/steps/SleepStep.ts +34 -0
  117. package/src/internal/runtime/steps/SleepUntilStep.ts +34 -0
  118. package/src/internal/runtime/steps/StepRun.ts +95 -0
  119. package/src/internal/runtime/steps/WaitForEventStep.ts +55 -0
  120. package/src/internal/serve/HttpApp.ts +123 -0
  121. package/src/internal/serve/Request.ts +27 -0
  122. package/src/internal/serve/Signature.ts +170 -0
  123. package/src/internal/wire/Duration.ts +31 -0
  124. package/src/internal/wire/Timestamp.ts +11 -0
  125. package/dist/_virtual/rolldown_runtime.js +0 -18
  126. package/dist/internal/constants.js +0 -15
  127. package/dist/internal/driver.d.ts +0 -5
  128. package/dist/internal/helpers.js +0 -44
  129. package/dist/internal/interrupts.d.ts +0 -2
  130. package/dist/internal/interrupts.js +0 -45
  131. package/dist/internal/memo.js +0 -56
  132. package/dist/internal/signature.d.ts +0 -18
  133. package/dist/internal/signature.js +0 -97
  134. package/dist/internal/step.d.ts +0 -59
  135. package/dist/internal/step.js +0 -192
  136. package/src/internal/constants.ts +0 -11
  137. package/src/internal/helpers.ts +0 -58
  138. package/src/internal/interrupts.ts +0 -62
  139. package/src/internal/memo.ts +0 -73
  140. package/src/internal/signature.ts +0 -158
  141. package/src/internal/step.ts +0 -394
@@ -0,0 +1,123 @@
1
+ import { InngestClient } from "../../Client.js";
2
+ import { Array, Context, DateTime, Effect, Encoding, Layer, Option, Schema } from "effect";
3
+ import * as NodeCrypto from "node:crypto";
4
+ //#region src/internal/serve/Signature.ts
5
+ var SignatureError = class extends Schema.TaggedErrorClass()("SignatureError", {
6
+ reason: Schema.Literals([
7
+ "missing_header",
8
+ "invalid_format",
9
+ "expired",
10
+ "invalid_signature",
11
+ "missing_signing_key"
12
+ ]),
13
+ message: Schema.String
14
+ }) {};
15
+ var SignatureConfig = class extends Schema.Class("effect-inngest/SignatureConfig")({
16
+ verification: Schema.Literals(["disabled", "required"]),
17
+ signingKey: Schema.OptionFromUndefinedOr(Schema.String),
18
+ signingKeyFallback: Schema.OptionFromUndefinedOr(Schema.String)
19
+ }) {};
20
+ var PreparedSigningKey = class PreparedSigningKey extends Schema.Class("effect-inngest/PreparedSigningKey")({ bytes: Schema.Uint8Array }) {
21
+ static decode = (signingKey) => Schema.decodeUnknownOption(Schema.Uint8ArrayFromHex)(signingKey.replace(/^signkey-\w+-/, "")).pipe(Option.map((bytes) => PreparedSigningKey.make({ bytes })));
22
+ sign = (body, timestampSeconds) => NodeCrypto.createHmac("sha256", this.bytes).update(body).update(String(timestampSeconds)).digest();
23
+ verifies = (body, header) => {
24
+ const expected = this.sign(body, header.timestampSeconds);
25
+ return expected.length === header.signature.length && NodeCrypto.timingSafeEqual(expected, header.signature);
26
+ };
27
+ };
28
+ var SignatureHeader = class SignatureHeader extends Schema.Class("effect-inngest/SignatureHeader")({
29
+ timestampSeconds: Schema.Number,
30
+ signature: Schema.Uint8Array
31
+ }) {
32
+ static decode = (header) => {
33
+ const params = new URLSearchParams(header);
34
+ return Schema.decodeUnknownEffect(SignatureParams)({
35
+ t: params.get("t") ?? "",
36
+ s: params.get("s") ?? ""
37
+ }).pipe(Effect.map(({ t, s }) => SignatureHeader.make({
38
+ timestampSeconds: t,
39
+ signature: s
40
+ })), Effect.mapError(() => SignatureError.make({
41
+ reason: "invalid_format",
42
+ message: `Invalid signature format: expected t=<int>&s=<64-hex>, got: ${header}`
43
+ })));
44
+ };
45
+ assertFresh = Effect.fn("effect-inngest/SignatureHeader/assertFresh")(function* () {
46
+ const now = yield* DateTime.now;
47
+ if (Math.abs(now.epochMilliseconds - this.timestampSeconds * 1e3) <= SIGNATURE_VALIDITY_WINDOW_MS) return;
48
+ return yield* SignatureError.make({
49
+ reason: "expired",
50
+ message: `Signature expired: timestamp ${this.timestampSeconds} is outside the validity window`
51
+ });
52
+ });
53
+ };
54
+ var SignedPayload = class extends Schema.Class("effect-inngest/SignedPayload")({
55
+ body: Schema.Uint8Array,
56
+ signature: Schema.OptionFromUndefinedOr(SignatureHeader)
57
+ }) {
58
+ requireSignature = Effect.fn("effect-inngest/SignedPayload/requireSignature")(function() {
59
+ return Effect.fromOption(this.signature).pipe(Effect.mapError(() => SignatureError.make({
60
+ reason: "missing_header",
61
+ message: "Missing Inngest signature header"
62
+ })));
63
+ });
64
+ };
65
+ const SIGNATURE_VALIDITY_WINDOW_MS = 300 * 1e3;
66
+ const TimestampSeconds = Schema.NumberFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThan(0)));
67
+ const SignatureParams = Schema.Struct({
68
+ t: TimestampSeconds,
69
+ s: Schema.Uint8ArrayFromHex
70
+ });
71
+ const hashSigningKey = (signingKey) => Encoding.encodeHex(NodeCrypto.createHash("sha256").update(signingKey.replace(/^signkey-\w+-/, "")).digest());
72
+ var Signature = class extends Context.Service()("effect-inngest/Signature", { make: (config) => Effect.gen(function* () {
73
+ const signingKey = config.signingKey.pipe(Option.flatMap(PreparedSigningKey.decode));
74
+ const fallbackSigningKey = config.signingKeyFallback.pipe(Option.flatMap(PreparedSigningKey.decode));
75
+ if (Option.isSome(config.signingKey) && Option.isNone(signingKey)) return yield* SignatureError.make({
76
+ reason: "invalid_format",
77
+ message: "Invalid signing key"
78
+ });
79
+ if (Option.isSome(config.signingKeyFallback) && Option.isNone(fallbackSigningKey)) return yield* SignatureError.make({
80
+ reason: "invalid_format",
81
+ message: "Invalid signing key"
82
+ });
83
+ const signingKeys = Array.getSomes([signingKey, fallbackSigningKey]);
84
+ return {
85
+ verify: Effect.fn("effect-inngest/Signature/verify")(function* (payload) {
86
+ if (config.verification === "disabled") return;
87
+ if (Array.isArrayEmpty(signingKeys)) return yield* SignatureError.make({
88
+ reason: "missing_signing_key",
89
+ message: "No signing key configured for signature verification"
90
+ });
91
+ const header = yield* payload.requireSignature();
92
+ yield* header.assertFresh();
93
+ if (!signingKeys.some((key) => key.verifies(payload.body, header))) return yield* SignatureError.make({
94
+ reason: "invalid_signature",
95
+ message: "Invalid signature"
96
+ });
97
+ }),
98
+ sign: Effect.fn("effect-inngest/Signature/sign")(function* (body) {
99
+ const key = yield* Option.match(signingKey, {
100
+ onNone: () => SignatureError.make({
101
+ reason: "missing_signing_key",
102
+ message: "No signing key configured for signing"
103
+ }),
104
+ onSome: Effect.succeed
105
+ });
106
+ const now = yield* DateTime.now;
107
+ const timestampSeconds = Math.floor(now.epochMilliseconds / 1e3);
108
+ return `t=${timestampSeconds}&s=${Encoding.encodeHex(key.sign(body, timestampSeconds))}`;
109
+ })
110
+ };
111
+ }) }) {
112
+ static layer = (config) => Layer.effect(this, this.make(config));
113
+ };
114
+ const SignatureLive = Layer.effect(Signature, Effect.gen(function* () {
115
+ const client = yield* InngestClient;
116
+ return yield* Signature.make(SignatureConfig.make({
117
+ verification: client.mode === "dev" ? "disabled" : "required",
118
+ signingKey: Option.fromNullishOr(client.config.signingKey),
119
+ signingKeyFallback: Option.fromNullishOr(client.config.signingKeyFallback)
120
+ }));
121
+ }));
122
+ //#endregion
123
+ export { Signature, SignatureError, SignatureHeader, SignatureLive, SignedPayload, hashSigningKey };
@@ -0,0 +1,19 @@
1
+ import { Duration, Schema, SchemaGetter, SchemaTransformation } from "effect";
2
+ const InngestDuration = Schema.Duration.pipe(Schema.check(Schema.makeFilter((duration) => Duration.isFinite(duration) && !Duration.isNegative(duration) ? void 0 : "a finite non-negative duration"))).pipe(Schema.encodeTo(Schema.String, {
3
+ decode: SchemaTransformation.durationFromString.decode,
4
+ encode: SchemaGetter.transform((duration) => {
5
+ const parts = Duration.parts(duration);
6
+ const weeks = Math.floor(parts.days / 7);
7
+ const days = parts.days % 7;
8
+ return [
9
+ weeks > 0 ? `${weeks}w` : "",
10
+ days > 0 ? `${days}d` : "",
11
+ parts.hours > 0 ? `${parts.hours}h` : "",
12
+ parts.minutes > 0 ? `${parts.minutes}m` : "",
13
+ parts.seconds > 0 ? `${parts.seconds}s` : "",
14
+ parts.millis > 0 ? `${parts.millis}ms` : ""
15
+ ].join("") || "0s";
16
+ })
17
+ }));
18
+ //#endregion
19
+ export { InngestDuration };
@@ -0,0 +1,14 @@
1
+ import { DateTime, Predicate, Schema, SchemaTransformation, identity } from "effect";
2
+ //#region src/internal/wire/Timestamp.ts
3
+ const TimestampInput = Schema.Union([
4
+ Schema.String,
5
+ Schema.Number,
6
+ Schema.DateValid
7
+ ]);
8
+ const decode = (value) => Predicate.isString(value) ? value : DateTime.formatIso(DateTime.makeUnsafe(value));
9
+ const InngestTimestamp = TimestampInput.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.transform({
10
+ decode,
11
+ encode: identity
12
+ })));
13
+ //#endregion
14
+ export { InngestTimestamp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-inngest",
3
- "version": "0.1.3",
3
+ "version": "0.3.0-beta.0",
4
4
  "description": "Native Effect client library for Inngest - build durable, type-safe workflows",
5
5
  "keywords": [
6
6
  "background-jobs",
@@ -91,33 +91,45 @@
91
91
  }
92
92
  },
93
93
  "scripts": {
94
- "build": "tsdown",
95
- "format": "oxfmt",
96
- "lint": "oxlint --fix --type-aware --type-check --promise-plugin",
94
+ "build": "vp pack",
95
+ "test": "vp test run",
96
+ "coverage": "vp test run --coverage",
97
+ "test:watch": "vp test",
98
+ "format": "vp fmt",
99
+ "lint": "vp lint --fix --type-aware --type-check --promise-plugin && sg scan src",
100
+ "ast-grep": "sg scan src",
97
101
  "typecheck": "tsgo --noEmit",
98
- "prepare": "effect-language-service patch",
102
+ "prepare": "vp config && effect-tsgo patch",
99
103
  "changeset": "changeset",
100
104
  "changeset:version": "changeset version",
101
105
  "changeset:publish": "bun run build && changeset publish"
102
106
  },
103
107
  "devDependencies": {
104
- "@changesets/changelog-github": "^0.5.2",
105
- "@changesets/cli": "^2.29.8",
106
- "@effect/cluster": "^0.56.1",
107
- "@effect/language-service": "0.72.0",
108
- "@effect/platform": "^0.94.2",
109
- "@effect/platform-bun": "^0.87.0",
110
- "@types/bun": "1.3.6",
111
- "@typescript/native-preview": "^7.0.0-dev.20260124.1",
112
- "effect": "^3.15.0",
113
- "oxfmt": "0.26.0",
114
- "oxlint": "1.41.0",
115
- "oxlint-tsgolint": "0.11.1",
116
- "tsdown": "0.20.1",
117
- "typescript": "^5.9.3"
108
+ "@ast-grep/cli": "^0.43.0",
109
+ "@changesets/changelog-github": "^0.6.0",
110
+ "@changesets/cli": "^2.31.0",
111
+ "@effect/platform-bun": "4.0.0-beta.83",
112
+ "@effect/tsgo": "0.14.3",
113
+ "@effect/vitest": "4.0.0-beta.83",
114
+ "@types/bun": "1.3.12",
115
+ "@typescript/native-preview": "^7.0.0-dev.20260420.1",
116
+ "@vitest/coverage-v8": "4.1.5",
117
+ "effect": "4.0.0-beta.83",
118
+ "inngest": "^4.5.1",
119
+ "mockttp": "^4.4.2",
120
+ "typescript": "^5.9.3",
121
+ "vite-plus": "latest"
118
122
  },
119
123
  "peerDependencies": {
120
- "@effect/platform": ">=0.87.0",
121
- "effect": ">=3.15.0"
122
- }
124
+ "effect": ">=4.0.0-beta.83",
125
+ "typescript": ">=5.9"
126
+ },
127
+ "overrides": {
128
+ "vite": "npm:@voidzero-dev/vite-plus-core@latest",
129
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
130
+ },
131
+ "packageManager": "bun@1.3.13",
132
+ "trustedDependencies": [
133
+ "@ast-grep/cli"
134
+ ]
123
135
  }
package/src/Client.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * @since 0.1.0
3
3
  */
4
- import * as HttpClient from "@effect/platform/HttpClient";
5
- import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
6
- import * as HttpClientResponse from "@effect/platform/HttpClientResponse";
7
- import * as Config from "effect/Config";
8
- import type * as ConfigError from "effect/ConfigError";
9
- import * as Context from "effect/Context";
10
- import * as Effect from "effect/Effect";
11
- import * as Layer from "effect/Layer";
12
- import * as Option from "effect/Option";
13
- import * as Predicate from "effect/Predicate";
14
- import * as Schema from "effect/Schema";
4
+ import * as HttpClient from "effect/unstable/http/HttpClient";
5
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
6
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
7
+ import { Clock, Config, Context, Effect, Layer, Option, Predicate, Ref, Schedule, Schema } from "effect";
8
+ import { CheckpointApiError, type CheckpointingOption } from "./internal/checkpoint.js";
15
9
  import * as Protocol from "./internal/protocol.js";
10
+ import { hashSigningKey } from "./internal/serve/Signature.js";
11
+
12
+ export type { CheckpointingOption } from "./internal/checkpoint.js";
13
+ export { CheckpointApiError } from "./internal/checkpoint.js";
16
14
 
17
15
  /**
18
16
  * @since 0.1.0
@@ -35,7 +33,7 @@ const SDK_VERSION = "2.0.0";
35
33
 
36
34
  type ClientMode = "dev" | "cloud";
37
35
 
38
- interface ClientConfig {
36
+ export interface ClientConfig {
39
37
  /**
40
38
  * The ID of this instance, most commonly a reference to the application it
41
39
  * resides in.
@@ -111,6 +109,29 @@ interface ClientConfig {
111
109
  * The path where the Inngest serve handler is mounted. Used for registration.
112
110
  */
113
111
  readonly servePath?: string | undefined;
112
+
113
+ /**
114
+ * The framework adapter serving this app, e.g. "bun" or "nodejs". Used for
115
+ * protocol headers and registration metadata.
116
+ */
117
+ readonly framework?: string | undefined;
118
+
119
+ /**
120
+ * Whether to use checkpointing by default for executions of functions
121
+ * created using this client.
122
+ *
123
+ * - `false` disables checkpointing for all functions on this client.
124
+ * - `true` enables checkpointing with safe defaults (`bufferedSteps: 1`,
125
+ * `maxInterval: 0`, `maxRuntime: 10s`). Steps are checkpointed to the
126
+ * Inngest API as they complete instead of yielding a 206 per step.
127
+ * - An object lets you tune `bufferedSteps`, `maxInterval`, `maxRuntime`.
128
+ *
129
+ * Per-function `checkpointing` overrides this setting. Defaults to `true`
130
+ * (checkpointing enabled).
131
+ *
132
+ * @default true
133
+ */
134
+ readonly checkpointing?: CheckpointingOption | undefined;
114
135
  }
115
136
 
116
137
  export const EventPayload = Schema.Struct({
@@ -122,14 +143,14 @@ export const EventPayload = Schema.Struct({
122
143
  });
123
144
 
124
145
  const SendEventResponse = Schema.Struct({
125
- ids: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
146
+ ids: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
126
147
  status: Schema.optional(Schema.Number),
127
148
  });
128
149
 
129
150
  type EventPayload = typeof EventPayload.Type;
130
151
  type SendEventResponse = typeof SendEventResponse.Type;
131
152
 
132
- class SendEventError extends Schema.TaggedError<SendEventError>()("SendEventError", {
153
+ class SendEventError extends Schema.TaggedErrorClass<SendEventError>()("SendEventError", {
133
154
  message: Schema.String,
134
155
  events: Schema.Array(Schema.String),
135
156
  }) {}
@@ -141,6 +162,26 @@ interface InngestClientService {
141
162
  readonly eventBaseUrl: string;
142
163
  readonly apiBaseUrl: string;
143
164
  readonly sendEvent: (events: ReadonlyArray<EventPayload>) => Effect.Effect<SendEventResponse, SendEventError>;
165
+ /**
166
+ * Async checkpoint API call per spec §10.3.1.
167
+ *
168
+ * POSTs `{run_id, fn_id, qi_id, steps, ts}` to
169
+ * `{apiBaseUrl}/v1/checkpoint/{runId}/async` with a hashed-signing-key
170
+ * Bearer token. Retries 5xx responses with exponential backoff + jitter
171
+ * (5 attempts). On 401, falls back to the configured fallback signing key
172
+ * for the remainder of the client's lifetime (one-way switch).
173
+ *
174
+ * @internal
175
+ */
176
+ readonly checkpointAsync: (args: {
177
+ readonly runId: string;
178
+ readonly fnId: string;
179
+ readonly qiId: string;
180
+ readonly requestId?: string;
181
+ readonly generationId?: number;
182
+ readonly requestStartedAt?: number;
183
+ readonly steps: ReadonlyArray<typeof Protocol.GeneratorOpcode.Type>;
184
+ }) => Effect.Effect<void, CheckpointApiError>;
144
185
  }
145
186
 
146
187
  /**
@@ -149,7 +190,20 @@ interface InngestClientService {
149
190
  * @since 0.1.0
150
191
  * @category context
151
192
  */
152
- export class InngestClient extends Context.Tag("effect-inngest/InngestClient")<InngestClient, InngestClientService>() {}
193
+ export class InngestClient extends Context.Service<InngestClient, InngestClientService>()(
194
+ "effect-inngest/InngestClient",
195
+ ) {}
196
+
197
+ /**
198
+ * Static Inngest application configuration.
199
+ *
200
+ * This is the narrow dependency for internals that need app settings but not
201
+ * client behavior such as event sending or checkpoint API calls.
202
+ *
203
+ * @since 0.1.0
204
+ * @category context
205
+ */
206
+ export class InngestConfig extends Context.Service<InngestConfig, ClientConfig>()("effect-inngest/InngestConfig") {}
153
207
 
154
208
  const resolveMode = (config: ClientConfig): ClientMode => config.mode ?? "dev";
155
209
 
@@ -159,64 +213,181 @@ const resolveEventBaseUrl = (config: ClientConfig, mode: ClientMode): string =>
159
213
  const resolveApiBaseUrl = (config: ClientConfig, mode: ClientMode): string =>
160
214
  config.apiBaseUrl ?? (mode === "dev" ? DEFAULT_DEV_SERVER_URL : DEFAULT_API_BASE_URL);
161
215
 
162
- const makeClient = (config: ClientConfig, httpClient: HttpClient.HttpClient): InngestClientService => {
163
- const mode = resolveMode(config);
164
- const eventBaseUrl = resolveEventBaseUrl(config, mode);
165
- const apiBaseUrl = resolveApiBaseUrl(config, mode);
166
-
167
- const sendEvent = (events: ReadonlyArray<EventPayload>): Effect.Effect<SendEventResponse, SendEventError> => {
168
- if (!config.eventKey && mode === "cloud") {
169
- return new SendEventError({
170
- message: "Event key is required to send events in cloud mode",
171
- events: events.map((e) => e.name),
172
- });
173
- }
174
-
175
- const key = config.eventKey ?? "local";
176
- const url = new URL(`e/${key}`, eventBaseUrl).toString();
177
- const eventNames = events.map((e) => e.name);
178
- const now = Date.now();
179
-
180
- const payloads = events.map((e) => ({
181
- name: e.name,
182
- data: e.data ?? {},
183
- ts: e.ts ?? now,
184
- id: e.id,
185
- v: e.v,
186
- }));
187
-
188
- const request = HttpClientRequest.post(url).pipe(
189
- HttpClientRequest.setHeaders({
190
- "Content-Type": "application/json",
191
- [Protocol.Headers.SDK]: `effect-ts:v${SDK_VERSION}`,
192
- ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
193
- }),
194
- );
216
+ const isRetriable = (error: CheckpointApiError): boolean => Predicate.isUndefined(error.status) || error.status >= 500;
217
+
218
+ const defaultCheckpointRetrySchedule: Schedule.Schedule<unknown, CheckpointApiError> = Schedule.exponential(
219
+ "100 millis",
220
+ 2.0,
221
+ ).pipe(
222
+ Schedule.jittered,
223
+ Schedule.both(Schedule.recurs(5)),
224
+ Schedule.while((m: Schedule.Metadata<unknown, CheckpointApiError>) => isRetriable(m.input)),
225
+ );
226
+
227
+ export const CheckpointRetrySchedule = Context.Reference<Schedule.Schedule<unknown, CheckpointApiError>>(
228
+ "effect-inngest/internal/CheckpointRetrySchedule",
229
+ { defaultValue: () => defaultCheckpointRetrySchedule },
230
+ );
195
231
 
196
- return HttpClientRequest.schemaBodyJson(Schema.Array(EventPayload))(request, payloads).pipe(
197
- Effect.flatMap(httpClient.execute),
198
- Effect.flatMap(HttpClientResponse.schemaBodyJson(SendEventResponse)),
199
- Effect.map((response) => ({ ids: response.ids, status: response.status })),
200
- Effect.scoped,
201
- Effect.catchAll(
202
- (error) =>
232
+ const makeClient = (config: ClientConfig, httpClient: HttpClient.HttpClient): Effect.Effect<InngestClientService> =>
233
+ Effect.gen(function* () {
234
+ const mode = resolveMode(config);
235
+ const eventBaseUrl = resolveEventBaseUrl(config, mode);
236
+ const apiBaseUrl = resolveApiBaseUrl(config, mode);
237
+ const retrySchedule = yield* CheckpointRetrySchedule;
238
+
239
+ /**
240
+ * One-way switch: once the fallback signing key succeeds for any
241
+ * checkpoint API call, all subsequent calls use it first. Per spec
242
+ * §10.3.4.
243
+ */
244
+ const useFallbackKey = yield* Ref.make(false);
245
+
246
+ const sendEvent = Effect.fn("InngestClient.sendEvent")(function* (events: ReadonlyArray<EventPayload>) {
247
+ if (!config.eventKey && mode === "cloud") {
248
+ return yield* Effect.fail(
203
249
  new SendEventError({
204
- message: `Failed to send events: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
205
- events: eventNames,
250
+ message: "Event key is required to send events in cloud mode",
251
+ events: events.map((e) => e.name),
206
252
  }),
207
- ),
253
+ );
254
+ }
255
+
256
+ const key = config.eventKey ?? "NO_EVENT_KEY_SET";
257
+ const url = new URL(`e/${key}`, eventBaseUrl).toString();
258
+ const eventNames = events.map((e) => e.name);
259
+ const now = yield* Clock.currentTimeMillis;
260
+
261
+ const payloads = events.map((e) => ({
262
+ name: e.name,
263
+ data: e.data ?? {},
264
+ ts: e.ts ?? now,
265
+ id: e.id,
266
+ v: e.v,
267
+ }));
268
+
269
+ const request = HttpClientRequest.post(url).pipe(
270
+ HttpClientRequest.setHeaders({
271
+ "Content-Type": "application/json",
272
+ [Protocol.Headers.SDK]: `effect-ts:v${SDK_VERSION}`,
273
+ ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
274
+ }),
275
+ );
276
+
277
+ return yield* HttpClientRequest.schemaBodyJson(Schema.Array(EventPayload))(request, payloads).pipe(
278
+ Effect.flatMap(httpClient.execute),
279
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(SendEventResponse)),
280
+ Effect.map((response) => ({ ids: response.ids, status: response.status })),
281
+ Effect.scoped,
282
+ Effect.catch((error) =>
283
+ Effect.fail(
284
+ new SendEventError({
285
+ message: `Failed to send events: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
286
+ events: eventNames,
287
+ }),
288
+ ),
289
+ ),
290
+ );
291
+ });
292
+
293
+ /**
294
+ * Issue a checkpoint POST with the given signing key. Returns a tagged
295
+ * error on non-2xx so the caller can decide whether to retry, swap keys,
296
+ * or fall back.
297
+ */
298
+ const checkpointRequest = (
299
+ runId: string,
300
+ body: string,
301
+ hashedSigningKey: string,
302
+ ): Effect.Effect<void, CheckpointApiError> => {
303
+ const url = new URL(`v1/checkpoint/${runId}/async`, apiBaseUrl).toString();
304
+ const request = HttpClientRequest.post(url).pipe(
305
+ HttpClientRequest.setHeaders({
306
+ "Content-Type": "application/json",
307
+ ...(config.env ? { [Protocol.Headers.Env]: config.env } : {}),
308
+ }),
309
+ HttpClientRequest.bearerToken(hashedSigningKey),
310
+ HttpClientRequest.bodyText(body, "application/json"),
311
+ );
312
+
313
+ return httpClient.execute(request).pipe(
314
+ Effect.scoped,
315
+ Effect.catch((error) =>
316
+ Effect.fail(
317
+ new CheckpointApiError({
318
+ message: `Network error issuing checkpoint: ${Predicate.hasProperty(error, "message") ? (error.message as string) : String(error)}`,
319
+ }),
320
+ ),
321
+ ),
322
+ Effect.flatMap((response) => {
323
+ if (response.status >= 200 && response.status < 300) {
324
+ return Effect.void;
325
+ }
326
+ return Effect.fail(
327
+ new CheckpointApiError({
328
+ message: `Checkpoint API returned ${response.status}`,
329
+ status: response.status,
330
+ }),
331
+ );
332
+ }),
333
+ );
334
+ };
335
+
336
+ const checkpointAsync: InngestClientService["checkpointAsync"] = Effect.fn("InngestClient.checkpointAsync")(
337
+ function* (args) {
338
+ const signingKey = config.signingKey;
339
+ if (!signingKey && mode !== "dev") {
340
+ return yield* Effect.fail(
341
+ new CheckpointApiError({ message: "No signing key configured for checkpoint API" }),
342
+ );
343
+ }
344
+
345
+ const now = yield* Clock.currentTimeMillis;
346
+ const body = JSON.stringify({
347
+ run_id: args.runId,
348
+ fn_id: args.fnId,
349
+ qi_id: args.qiId,
350
+ request_id: args.requestId,
351
+ generation_id: args.generationId,
352
+ request_started_at: args.requestStartedAt,
353
+ steps: args.steps,
354
+ ts: now,
355
+ });
356
+
357
+ const fallbackKey = config.signingKeyFallback;
358
+
359
+ // Single attempt: tries the chosen key first; on 401 with primary,
360
+ // tries the fallback once and flips the Ref.
361
+ const attempt: Effect.Effect<void, CheckpointApiError> = Ref.get(useFallbackKey).pipe(
362
+ Effect.flatMap((usingFallback) => {
363
+ const primaryKey = usingFallback && fallbackKey ? fallbackKey : signingKey;
364
+ const primaryToken = primaryKey ? hashSigningKey(primaryKey) : "";
365
+ return checkpointRequest(args.runId, body, primaryToken).pipe(
366
+ Effect.catch((error) =>
367
+ error.status === 401 && !usingFallback && fallbackKey
368
+ ? checkpointRequest(args.runId, body, hashSigningKey(fallbackKey)).pipe(
369
+ Effect.andThen(Ref.set(useFallbackKey, true)),
370
+ )
371
+ : Effect.fail(error),
372
+ ),
373
+ );
374
+ }),
375
+ );
376
+
377
+ return yield* attempt.pipe(Effect.retry(retrySchedule));
378
+ },
208
379
  );
209
- };
210
-
211
- return {
212
- [TypeId]: TypeId,
213
- config,
214
- mode,
215
- eventBaseUrl,
216
- apiBaseUrl,
217
- sendEvent,
218
- };
219
- };
380
+
381
+ return {
382
+ [TypeId]: TypeId,
383
+ config,
384
+ mode,
385
+ eventBaseUrl,
386
+ apiBaseUrl,
387
+ sendEvent,
388
+ checkpointAsync,
389
+ };
390
+ });
220
391
 
221
392
  /**
222
393
  * Create an InngestClient layer from a config.
@@ -231,10 +402,16 @@ const makeClient = (config: ClientConfig, httpClient: HttpClient.HttpClient): In
231
402
  * })
232
403
  * ```
233
404
  */
234
- export const layer = (config: ClientConfig): Layer.Layer<InngestClient, never, HttpClient.HttpClient> =>
235
- Layer.effect(
236
- InngestClient,
237
- Effect.map(HttpClient.HttpClient, (httpClient) => makeClient(config, httpClient)),
405
+ export const layer = (config: ClientConfig): Layer.Layer<InngestClient | InngestConfig, never, HttpClient.HttpClient> =>
406
+ Layer.mergeAll(
407
+ Layer.succeed(InngestConfig, config),
408
+ Layer.effect(
409
+ InngestClient,
410
+ Effect.gen(function* () {
411
+ const httpClient = yield* HttpClient.HttpClient;
412
+ return yield* makeClient(config, httpClient);
413
+ }),
414
+ ),
238
415
  );
239
416
 
240
417
  /**
@@ -244,13 +421,13 @@ export const layer = (config: ClientConfig): Layer.Layer<InngestClient, never, H
244
421
  * @category layers
245
422
  */
246
423
  export const layerConfig = (
247
- config: Config.Config.Wrap<ClientConfig>,
248
- ): Layer.Layer<InngestClient, ConfigError.ConfigError, HttpClient.HttpClient> =>
249
- Layer.effect(
250
- InngestClient,
251
- Effect.flatMap(Config.unwrap(config), (resolvedConfig) =>
252
- Effect.map(HttpClient.HttpClient, (httpClient) => makeClient(resolvedConfig, httpClient)),
253
- ),
424
+ config: Config.Wrap<ClientConfig>,
425
+ ): Layer.Layer<InngestClient | InngestConfig, Config.ConfigError, HttpClient.HttpClient> =>
426
+ Layer.unwrap(
427
+ Effect.gen(function* () {
428
+ const resolvedConfig = yield* Config.unwrap(config);
429
+ return layer(resolvedConfig);
430
+ }),
254
431
  );
255
432
 
256
433
  /**
@@ -266,14 +443,15 @@ export const layerConfig = (
266
443
  * @since 0.1.0
267
444
  * @category layers
268
445
  */
269
- export const layerFromEnv: Layer.Layer<InngestClient, ConfigError.ConfigError, HttpClient.HttpClient> = layerConfig(
270
- Config.all({
271
- id: Config.string("INNGEST_APP_ID").pipe(Config.withDefault("app")),
272
- eventKey: Config.string("INNGEST_EVENT_KEY").pipe(Config.option, Config.map(Option.getOrUndefined)),
273
- signingKey: Config.string("INNGEST_SIGNING_KEY").pipe(Config.option, Config.map(Option.getOrUndefined)),
274
- signingKeyFallback: Config.string("INNGEST_SIGNING_KEY_FALLBACK").pipe(
275
- Config.option,
276
- Config.map(Option.getOrUndefined),
277
- ),
278
- }),
279
- );
446
+ export const layerFromEnv: Layer.Layer<InngestClient | InngestConfig, Config.ConfigError, HttpClient.HttpClient> =
447
+ layerConfig(
448
+ Config.all({
449
+ id: Config.string("INNGEST_APP_ID").pipe(Config.withDefault("app")),
450
+ eventKey: Config.string("INNGEST_EVENT_KEY").pipe(Config.option, Config.map(Option.getOrUndefined)),
451
+ signingKey: Config.string("INNGEST_SIGNING_KEY").pipe(Config.option, Config.map(Option.getOrUndefined)),
452
+ signingKeyFallback: Config.string("INNGEST_SIGNING_KEY_FALLBACK").pipe(
453
+ Config.option,
454
+ Config.map(Option.getOrUndefined),
455
+ ),
456
+ }),
457
+ );