effect-inngest 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/dist/Client.d.ts +167 -0
  4. package/dist/Client.js +144 -0
  5. package/dist/Events.d.ts +110 -0
  6. package/dist/Events.js +93 -0
  7. package/dist/Function.d.ts +384 -0
  8. package/dist/Function.js +104 -0
  9. package/dist/Group.d.ts +152 -0
  10. package/dist/Group.js +164 -0
  11. package/dist/HttpApi.d.ts +75 -0
  12. package/dist/HttpApi.js +47 -0
  13. package/dist/_virtual/rolldown_runtime.js +18 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +8 -0
  16. package/dist/internal/constants.js +15 -0
  17. package/dist/internal/driver.d.ts +5 -0
  18. package/dist/internal/driver.js +117 -0
  19. package/dist/internal/errors.d.ts +56 -0
  20. package/dist/internal/errors.js +61 -0
  21. package/dist/internal/handler.d.ts +20 -0
  22. package/dist/internal/handler.js +145 -0
  23. package/dist/internal/helpers.js +44 -0
  24. package/dist/internal/interrupts.d.ts +2 -0
  25. package/dist/internal/interrupts.js +45 -0
  26. package/dist/internal/memo.js +56 -0
  27. package/dist/internal/protocol.d.ts +1 -0
  28. package/dist/internal/protocol.js +191 -0
  29. package/dist/internal/signature.d.ts +18 -0
  30. package/dist/internal/signature.js +97 -0
  31. package/dist/internal/step.d.ts +59 -0
  32. package/dist/internal/step.js +183 -0
  33. package/package.json +121 -0
  34. package/src/Client.ts +279 -0
  35. package/src/Events.ts +87 -0
  36. package/src/Function.ts +493 -0
  37. package/src/Group.ts +314 -0
  38. package/src/HttpApi.ts +82 -0
  39. package/src/index.ts +171 -0
  40. package/src/internal/constants.ts +11 -0
  41. package/src/internal/driver.ts +194 -0
  42. package/src/internal/errors.ts +130 -0
  43. package/src/internal/handler.ts +222 -0
  44. package/src/internal/helpers.ts +58 -0
  45. package/src/internal/interrupts.ts +62 -0
  46. package/src/internal/memo.ts +73 -0
  47. package/src/internal/protocol.ts +218 -0
  48. package/src/internal/signature.ts +158 -0
  49. package/src/internal/step.ts +377 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Driver execution logic.
3
+ * @internal
4
+ */
5
+ import * as Headers from "@effect/platform/Headers";
6
+ import * as HttpTraceContext from "@effect/platform/HttpTraceContext";
7
+ import { Array as Arr, Chunk, Context, Duration, Effect, HashMap, Option, pipe, Predicate, Ref, Schema } from "effect";
8
+ import type { InngestFunction } from "../Function.js";
9
+ import { InngestClient } from "../Client.js";
10
+ import { isRetryAfterError, isNonRetriableError, isStepError, type RetryAfterError, type StepError } from "./errors.js";
11
+ import * as Protocol from "./protocol.js";
12
+ import { StepInterrupt } from "./interrupts.js";
13
+ import { createStepTools, buildHandlerContext, Step, type HandlerContext } from "./step.js";
14
+ import { OtelAttributes } from "./constants.js";
15
+
16
+ /** Trace context headers extracted from incoming request */
17
+ export interface TraceHeaders {
18
+ readonly traceparent?: string;
19
+ readonly tracestate?: string;
20
+ }
21
+
22
+ const SDK_VERSION = "2.0.0";
23
+
24
+ export class ExecutionResult extends Schema.Class<ExecutionResult>("ExecutionResult")({
25
+ status: Schema.Literal(200, 206, 500),
26
+ body: Schema.Unknown,
27
+ headers: Schema.Record({ key: Schema.String, value: Schema.String }),
28
+ }) {}
29
+
30
+ const isStepInterrupt = Schema.is(StepInterrupt);
31
+
32
+ const isSomeWithValue = (v: unknown): v is { _tag: "Some"; value: unknown } =>
33
+ Predicate.isRecord(v) && Predicate.hasProperty(v, "_tag") && v._tag === "Some" && Predicate.hasProperty(v, "value");
34
+
35
+ const extractStepInterrupt = (value: unknown): Option.Option<StepInterrupt> =>
36
+ pipe(
37
+ Option.liftPredicate(isStepInterrupt)(value),
38
+ Option.orElse(() =>
39
+ pipe(
40
+ Option.liftPredicate(isSomeWithValue)(value),
41
+ Option.flatMap((v) => Option.liftPredicate(isStepInterrupt)(v.value)),
42
+ ),
43
+ ),
44
+ );
45
+
46
+ const collectStepInterruptsFromError = (error: unknown): Chunk.Chunk<StepInterrupt> =>
47
+ pipe(
48
+ extractStepInterrupt(error),
49
+ Option.match({
50
+ onSome: Chunk.of,
51
+ onNone: () =>
52
+ Array.isArray(error) ? Chunk.fromIterable(Arr.filterMap(error, extractStepInterrupt)) : Chunk.empty(),
53
+ }),
54
+ );
55
+
56
+ const toUserError = (error: unknown): typeof Protocol.UserError.Type =>
57
+ Protocol.UserError.make({
58
+ name: Predicate.hasProperty(error, "name") ? String(error.name) : "Error",
59
+ message: Predicate.hasProperty(error, "message") ? String(error.message) : String(error),
60
+ stack: Predicate.hasProperty(error, "stack") ? String(error.stack) : undefined,
61
+ });
62
+
63
+ const baseHeaders = (): Record<string, string> => ({
64
+ "Content-Type": "application/json",
65
+ [Protocol.Headers.SDK]: `effect-inngest:v${SDK_VERSION}`,
66
+ [Protocol.Headers.RequestVersion]: "1",
67
+ });
68
+
69
+ export const execute = <F extends InngestFunction.Any, R>(
70
+ fn: F,
71
+ handler: (ctx: HandlerContext<F>) => Effect.Effect<InngestFunction.Success<F>, unknown, R>,
72
+ request: Protocol.SDKRequestBody,
73
+ appName: string,
74
+ traceHeaders: TraceHeaders = {},
75
+ ): Effect.Effect<ExecutionResult, never, R | InngestClient> =>
76
+ Effect.gen(function* () {
77
+ const stepIdCounts = yield* Ref.make(HashMap.empty<string, number>());
78
+ const step = createStepTools(request, appName, stepIdCounts);
79
+ const context = buildHandlerContext<F>(fn, step, request);
80
+ const headers = baseHeaders();
81
+
82
+ const result = yield* handler(context).pipe(
83
+ Effect.provideService(Step, step),
84
+ Effect.map((value) => ExecutionResult.make({ status: 200, body: value, headers })),
85
+ Effect.catchAll((error) => {
86
+ const interrupts = collectStepInterruptsFromError(error);
87
+
88
+ if (!Chunk.isEmpty(interrupts)) {
89
+ const interruptArray = Chunk.toReadonlyArray(interrupts);
90
+ const opcodes = interruptArray.map((interrupt) => interrupt.opcode);
91
+
92
+ const hasNonRetriableError = opcodes.some(
93
+ (op) =>
94
+ op.op === Protocol.Opcode.StepError &&
95
+ Predicate.isRecord(op.error) &&
96
+ Predicate.hasProperty(op.error, "noRetry") &&
97
+ op.error.noRetry === true,
98
+ );
99
+
100
+ const retryAfterMs = interruptArray.find((i) => i.retryAfterMs !== undefined)?.retryAfterMs;
101
+ const responseHeaders: Record<string, string> = hasNonRetriableError
102
+ ? { ...headers, [Protocol.Headers.NoRetry]: "true" }
103
+ : headers;
104
+ if (retryAfterMs !== undefined) {
105
+ responseHeaders[Protocol.Headers.RetryAfter] = String(Math.ceil(retryAfterMs / 1000));
106
+ }
107
+ const encodedOpcodes = Schema.encodeSync(Schema.Array(Protocol.GeneratorOpcode))(opcodes);
108
+ return Effect.succeed(ExecutionResult.make({ status: 206, body: encodedOpcodes, headers: responseHeaders }));
109
+ }
110
+
111
+ // Check for RetryAfterError - use type guard and direct access
112
+ if (isRetryAfterError(error)) {
113
+ const retryAfterMs = Duration.toMillis((error as RetryAfterError).retryAfter);
114
+ const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
115
+ return Effect.succeed(
116
+ ExecutionResult.make({
117
+ status: 500,
118
+ body: { error: toUserError(error) },
119
+ headers: {
120
+ ...headers,
121
+ [Protocol.Headers.NoRetry]: "false",
122
+ [Protocol.Headers.RetryAfter]: String(retryAfterSeconds),
123
+ },
124
+ }),
125
+ );
126
+ }
127
+
128
+ // Check for NonRetriableError or StepError with noRetry
129
+ const noRetryValue =
130
+ isNonRetriableError(error) || (isStepError(error) && (error as StepError).noRetry === true);
131
+ const noRetry = noRetryValue ? "true" : "false";
132
+ return Effect.succeed(
133
+ ExecutionResult.make({
134
+ status: 500,
135
+ body: { error: toUserError(error) },
136
+ headers: { ...headers, [Protocol.Headers.NoRetry]: noRetry },
137
+ }),
138
+ );
139
+ }),
140
+
141
+ Effect.catchAllDefect((defect) =>
142
+ Effect.succeed(
143
+ ExecutionResult.make({
144
+ status: 500,
145
+ body: { error: toUserError(defect) },
146
+ headers: { ...headers, [Protocol.Headers.NoRetry]: "false" },
147
+ }),
148
+ ),
149
+ ),
150
+ );
151
+
152
+ return result;
153
+ }).pipe(
154
+ (base) => {
155
+ const headers: Record<string, string> = {};
156
+ if (traceHeaders.traceparent) headers["traceparent"] = traceHeaders.traceparent;
157
+ if (traceHeaders.tracestate) headers["tracestate"] = traceHeaders.tracestate;
158
+ return pipe(
159
+ HttpTraceContext.fromHeaders(Headers.fromInput(headers)),
160
+ Option.match({
161
+ onNone: () => base,
162
+ onSome: (externalSpan) => Effect.withParentSpan(base, externalSpan),
163
+ }),
164
+ );
165
+ },
166
+ Effect.withSpan(`inngest.function/${fn._tag}`, {
167
+ kind: "server",
168
+ attributes: {
169
+ [OtelAttributes.RunId]: request.ctx.run_id,
170
+ [OtelAttributes.FunctionId]: fn._tag,
171
+ [OtelAttributes.AppId]: appName,
172
+ [OtelAttributes.Attempt]: request.ctx.attempt,
173
+ },
174
+ }),
175
+ );
176
+
177
+ export interface DriverService {
178
+ readonly execute: <F extends InngestFunction.Any, R>(
179
+ fn: F,
180
+ handler: (ctx: HandlerContext<F>) => Effect.Effect<InngestFunction.Success<F>, unknown, R>,
181
+ request: Protocol.SDKRequestBody,
182
+ ) => Effect.Effect<ExecutionResult, never, R | InngestClient>;
183
+ }
184
+
185
+ export class Driver extends Context.Tag("effect-inngest/Driver")<Driver, DriverService>() {}
186
+
187
+ export const layer = (options: { readonly appName: string }) =>
188
+ Effect.succeed({
189
+ execute: <F extends InngestFunction.Any, R>(
190
+ fn: F,
191
+ handler: (ctx: HandlerContext<F>) => Effect.Effect<InngestFunction.Success<F>, unknown, R>,
192
+ request: Protocol.SDKRequestBody,
193
+ ) => execute(fn, handler, request, options.appName),
194
+ }).pipe(Effect.map((service) => Context.make(Driver, service)));
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Internal error types for the Effect Inngest SDK.
3
+ * @internal
4
+ */
5
+ import * as Schema from "effect/Schema";
6
+
7
+ /**
8
+ * @internal
9
+ */
10
+ export class SignatureError extends Schema.TaggedError<SignatureError>()("SignatureError", {
11
+ message: Schema.String,
12
+ }) {}
13
+
14
+ /**
15
+ * @internal
16
+ */
17
+ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("RegistrationError", {
18
+ message: Schema.String,
19
+ functions: Schema.Array(Schema.String),
20
+ }) {}
21
+
22
+ /**
23
+ * @internal
24
+ */
25
+ export class FunctionNotFoundError extends Schema.TaggedError<FunctionNotFoundError>()("FunctionNotFoundError", {
26
+ message: Schema.String,
27
+ functionId: Schema.String,
28
+ }) {}
29
+
30
+ /**
31
+ * @internal
32
+ */
33
+ export class SendEventError extends Schema.TaggedError<SendEventError>()("SendEventError", {
34
+ message: Schema.String,
35
+ events: Schema.Array(Schema.String),
36
+ }) {}
37
+
38
+ /**
39
+ * @internal
40
+ */
41
+ export class UseApiFetchError extends Schema.TaggedError<UseApiFetchError>()("UseApiFetchError", {
42
+ message: Schema.String,
43
+ endpoint: Schema.Literal("batch", "actions"),
44
+ runId: Schema.String,
45
+ statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
46
+ }) {}
47
+
48
+ /**
49
+ * @internal
50
+ */
51
+ export class StepError extends Schema.TaggedError<StepError>()("StepError", {
52
+ message: Schema.String,
53
+ stepId: Schema.String,
54
+ cause: Schema.optional(Schema.Unknown),
55
+ noRetry: Schema.optional(Schema.Boolean),
56
+ }) {}
57
+
58
+ /**
59
+ * @internal
60
+ */
61
+ export const isStepError = Schema.is(StepError);
62
+
63
+ /**
64
+ * @internal
65
+ */
66
+ export class TimeoutError extends Schema.TaggedError<TimeoutError>()("TimeoutError", {
67
+ message: Schema.String,
68
+ stepId: Schema.optional(Schema.String),
69
+ timeout: Schema.DurationFromMillis,
70
+ }) {}
71
+
72
+ /**
73
+ * Thrown to indicate that the error should not be retried.
74
+ * Use this when you know retrying won't help (e.g., validation errors, auth failures).
75
+ *
76
+ * @since 0.1.0
77
+ * @category errors
78
+ */
79
+ export class NonRetriableError extends Schema.TaggedError<NonRetriableError>()("NonRetriableError", {
80
+ message: Schema.String,
81
+ cause: Schema.optional(Schema.Unknown),
82
+ }) {}
83
+
84
+ /**
85
+ * @internal
86
+ */
87
+ export const isNonRetriableError = Schema.is(NonRetriableError);
88
+
89
+ /**
90
+ * Thrown to indicate that the operation should be retried after a specific delay.
91
+ * Use this for rate limiting or when you know when a resource will become available.
92
+ *
93
+ * @since 0.1.0
94
+ * @category errors
95
+ */
96
+ export class RetryAfterError extends Schema.TaggedError<RetryAfterError>()("RetryAfterError", {
97
+ message: Schema.String,
98
+ retryAfter: Schema.DurationFromMillis,
99
+ cause: Schema.optional(Schema.Unknown),
100
+ }) {}
101
+
102
+ /**
103
+ * @internal
104
+ */
105
+ export const isRetryAfterError = Schema.is(RetryAfterError);
106
+
107
+ /**
108
+ * @internal
109
+ */
110
+ export type ServerError = SignatureError | RegistrationError | FunctionNotFoundError;
111
+
112
+ /**
113
+ * @internal
114
+ */
115
+ export type ClientError = SendEventError | UseApiFetchError;
116
+
117
+ /**
118
+ * @internal
119
+ */
120
+ export type ExecutionError = StepError | TimeoutError;
121
+
122
+ /**
123
+ * @internal
124
+ */
125
+ export type RetryControlError = NonRetriableError | RetryAfterError;
126
+
127
+ /**
128
+ * @internal
129
+ */
130
+ export type InngestError = ServerError | ClientError | ExecutionError | RetryControlError;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Internal handler implementation.
3
+ * @internal
4
+ */
5
+ import * as HttpClient from "@effect/platform/HttpClient";
6
+ import * as HttpClientRequest from "@effect/platform/HttpClientRequest";
7
+ import * as HttpClientResponse from "@effect/platform/HttpClientResponse";
8
+ import * as HttpServerRequest from "@effect/platform/HttpServerRequest";
9
+ import * as Context from "effect/Context";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Predicate from "effect/Predicate";
12
+ import * as Schema from "effect/Schema";
13
+ import { InngestClient } from "../Client.js";
14
+ import type { InngestFunction } from "../Function.js";
15
+ import type { InngestGroup } from "../Group.js";
16
+ import { Signature, SignatureError } from "./signature.js";
17
+ import * as Protocol from "./protocol.js";
18
+ export { SignatureError } from "./signature.js";
19
+ import { execute, type TraceHeaders } from "./driver.js";
20
+ export type { TraceHeaders } from "./driver.js";
21
+
22
+ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()("InvalidRequestError", {
23
+ message: Schema.String,
24
+ }) {}
25
+
26
+ const SDK_VERSION = "2.0.0";
27
+
28
+ const baseHeaders = (): Record<string, string> => ({
29
+ "Content-Type": "application/json",
30
+ [Protocol.Headers.SDK]: `effect-inngest:v${SDK_VERSION}`,
31
+ [Protocol.Headers.RequestVersion]: "1",
32
+ });
33
+
34
+ const buildServeUrl = (requestUrl: string, serveHost?: string, servePath?: string): URL => {
35
+ const url = new URL(requestUrl);
36
+ if (servePath) {
37
+ url.pathname = servePath;
38
+ }
39
+ if (serveHost) {
40
+ return new URL(url.pathname + url.search, serveHost);
41
+ }
42
+ return url;
43
+ };
44
+
45
+ export interface HandlerResponse<T> {
46
+ readonly status: number;
47
+ readonly headers: Record<string, string>;
48
+ readonly body: T;
49
+ }
50
+
51
+ export const verifyAndParseRequestBody = (
52
+ request: HttpServerRequest.HttpServerRequest,
53
+ ): Effect.Effect<
54
+ typeof Protocol.SDKRequestBody.Type,
55
+ SignatureError | InvalidRequestError,
56
+ InngestClient | Signature
57
+ > =>
58
+ Effect.gen(function* () {
59
+ const client = yield* InngestClient;
60
+ const sig = yield* Signature;
61
+ const config = client.config;
62
+ const isDev = client.mode === "dev";
63
+
64
+ const bodyText = yield* request.text.pipe(
65
+ Effect.mapError((error) => new InvalidRequestError({ message: `Failed to read request body: ${String(error)}` })),
66
+ );
67
+
68
+ yield* sig.verify({
69
+ body: new TextEncoder().encode(bodyText),
70
+ signatureHeader: request.headers[Protocol.Headers.Signature.toLowerCase()],
71
+ signingKey: config.signingKey,
72
+ signingKeyFallback: config.signingKeyFallback,
73
+ isDev,
74
+ });
75
+
76
+ return yield* Schema.decodeUnknown(Schema.parseJson(Protocol.SDKRequestBody))(bodyText).pipe(
77
+ Effect.mapError((error) => new InvalidRequestError({ message: `Invalid request body: ${String(error)}` })),
78
+ );
79
+ });
80
+
81
+ export const handleIntrospection = (
82
+ group: InngestGroup.Any,
83
+ _requestUrl: string,
84
+ ): Effect.Effect<HandlerResponse<typeof Protocol.IntrospectionResponse.Type>, never, InngestClient> =>
85
+ Effect.gen(function* () {
86
+ const client = yield* InngestClient;
87
+ const config = client.config;
88
+
89
+ const body: typeof Protocol.IntrospectionResponse.Type = {
90
+ function_count: group.functions.size,
91
+ has_event_key: config.eventKey !== undefined,
92
+ has_signing_key: config.signingKey !== undefined,
93
+ has_signing_key_fallback: config.signingKeyFallback !== undefined,
94
+ mode: client.mode === "dev" ? "dev" : "cloud",
95
+ schema_version: "2024-05-24",
96
+ authentication_succeeded: null,
97
+ };
98
+
99
+ return { status: 200, headers: baseHeaders(), body };
100
+ });
101
+
102
+ const RegisterRequest = Schema.Struct({
103
+ url: Schema.String,
104
+ v: Schema.String,
105
+ deployType: Schema.Literal("ping"),
106
+ sdk: Schema.String,
107
+ appName: Schema.String,
108
+ framework: Schema.String,
109
+ functions: Schema.Array(Schema.Unknown),
110
+ });
111
+
112
+ export const handleRegistration = (
113
+ group: InngestGroup.Any,
114
+ requestUrl: string,
115
+ ): Effect.Effect<
116
+ HandlerResponse<typeof Protocol.RegisterResponse.Type>,
117
+ never,
118
+ InngestClient | HttpClient.HttpClient
119
+ > =>
120
+ Effect.gen(function* () {
121
+ const client = yield* InngestClient;
122
+ const httpClient = yield* HttpClient.HttpClient;
123
+ const config = client.config;
124
+ const url = buildServeUrl(requestUrl, config.serveHost, config.servePath);
125
+
126
+ const functions = Array.from(group.functions.values()).map((fn) =>
127
+ fn.toRegistration({ appId: config.id, url: url.href }),
128
+ );
129
+
130
+ const registerUrl = new URL("fn/register", client.apiBaseUrl).toString();
131
+ const request = HttpClientRequest.post(registerUrl).pipe(
132
+ HttpClientRequest.setHeaders({
133
+ ...baseHeaders(),
134
+ Authorization: `Bearer ${config.signingKey ?? ""}`,
135
+ }),
136
+ HttpClientRequest.schemaBodyJson(RegisterRequest)({
137
+ url: url.href,
138
+ v: "0.1",
139
+ deployType: "ping" as const,
140
+ sdk: `effect-inngest:v${SDK_VERSION}`,
141
+ appName: config.id,
142
+ framework: "effect",
143
+ functions,
144
+ }),
145
+ );
146
+
147
+ const response = yield* request.pipe(
148
+ Effect.flatMap(httpClient.execute),
149
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Protocol.RegisterResponse)),
150
+ Effect.scoped,
151
+ Effect.catchAll((error) =>
152
+ Effect.succeed({
153
+ message: `Registration failed: ${Predicate.hasProperty(error, "message") ? (error.message as string) : "Unknown error"}`,
154
+ }),
155
+ ),
156
+ );
157
+
158
+ return {
159
+ status: 200,
160
+ headers: baseHeaders(),
161
+ body: response,
162
+ };
163
+ });
164
+
165
+ export interface Handler<Tag extends string> {
166
+ readonly tag: Tag;
167
+ readonly handler: (ctx: any) => Effect.Effect<any, any, any>;
168
+ readonly context: Context.Context<any>;
169
+ }
170
+
171
+ export const handleExecution = (
172
+ group: InngestGroup.Any,
173
+ fnId: string,
174
+ urlStepId: string | undefined,
175
+ body: typeof Protocol.SDKRequestBody.Type,
176
+ traceHeaders: TraceHeaders = {},
177
+ ): Effect.Effect<HandlerResponse<unknown>, never, InngestClient> =>
178
+ Effect.gen(function* () {
179
+ const client = yield* InngestClient;
180
+ const context = yield* Effect.context<never>();
181
+
182
+ const appId = client.config.id;
183
+ const prefix = `${appId}-`;
184
+ const fnTag = fnId.startsWith(prefix) ? fnId.slice(prefix.length) : fnId;
185
+
186
+ const fn = group.functions.get(fnTag) as InngestFunction.Any | undefined;
187
+ const entry = fn ? (context.unsafeMap.get(fn.key) as Handler<string> | undefined) : undefined;
188
+
189
+ if (!fn || !entry) {
190
+ return {
191
+ status: 404 as const,
192
+ headers: { ...baseHeaders(), [Protocol.Headers.NoRetry]: "true" },
193
+ body: {
194
+ error: Protocol.UserError.make({ name: "FunctionNotFoundError", message: `Unknown function: ${fnId}` }),
195
+ },
196
+ };
197
+ }
198
+
199
+ // URL stepId takes precedence over body.ctx.step_id
200
+ // This is how Inngest requests execution of a specific step
201
+ const effectiveBody =
202
+ urlStepId && urlStepId !== body.ctx.step_id
203
+ ? Protocol.SDKRequestBody.make({
204
+ event: body.event,
205
+ events: body.events,
206
+ steps: body.steps,
207
+ ctx: Protocol.SDKRequestContext.make({
208
+ fn_id: body.ctx.fn_id,
209
+ run_id: body.ctx.run_id,
210
+ step_id: urlStepId,
211
+ attempt: body.ctx.attempt,
212
+ disable_immediate_execution: body.ctx.disable_immediate_execution,
213
+ use_api: body.ctx.use_api,
214
+ stack: body.ctx.stack,
215
+ }),
216
+ use_api: body.use_api,
217
+ })
218
+ : body;
219
+
220
+ const result = yield* Effect.provide(execute(fn, entry.handler, effectiveBody, appId, traceHeaders), entry.context);
221
+ return { status: result.status, headers: result.headers, body: result.body };
222
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Internal helper utilities.
3
+ * @internal
4
+ */
5
+ import * as Duration from "effect/Duration";
6
+
7
+ const millisecond = 1;
8
+ const second = millisecond * 1000;
9
+ const minute = second * 60;
10
+ const hour = minute * 60;
11
+ const day = hour * 24;
12
+ const week = day * 7;
13
+
14
+ const periods = [
15
+ ["w", week],
16
+ ["d", day],
17
+ ["h", hour],
18
+ ["m", minute],
19
+ ["s", second],
20
+ ] as const;
21
+
22
+ /**
23
+ * Convert a Duration to an Inngest-compatible time string (e.g. `"1d"` or `"2h30m"`).
24
+ *
25
+ * Supports weeks, days, hours, minutes, and seconds. Years/months are converted
26
+ * to their equivalent in weeks/days.
27
+ */
28
+ export const timeStr = (input: Duration.DurationInput): string => {
29
+ let ms = Duration.toMillis(Duration.decode(input));
30
+
31
+ const [, result] = periods.reduce<[number, string]>(
32
+ ([num, str], [suffix, period]) => {
33
+ const numPeriods = Math.floor(num / period);
34
+
35
+ if (numPeriods > 0) {
36
+ return [num % period, `${str}${numPeriods}${suffix}`];
37
+ }
38
+
39
+ return [num, str];
40
+ },
41
+ [ms, ""],
42
+ );
43
+
44
+ return result || "0s";
45
+ };
46
+
47
+ /**
48
+ * Format a timestamp as an ISO string for Inngest's sleepUntil.
49
+ */
50
+ export const formatTimestamp = (timestamp: Date | number | string): string => {
51
+ if (timestamp instanceof Date) {
52
+ return timestamp.toISOString();
53
+ }
54
+ if (typeof timestamp === "number") {
55
+ return new Date(timestamp).toISOString();
56
+ }
57
+ return timestamp;
58
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * StepInterrupt schema and factory functions.
3
+ * @internal
4
+ */
5
+ import * as Predicate from "effect/Predicate";
6
+ import * as Schema from "effect/Schema";
7
+ import * as Protocol from "./protocol.js";
8
+
9
+ /** @internal */
10
+ export class StepInterrupt extends Schema.TaggedClass<StepInterrupt>()("StepInterrupt", {
11
+ opcode: Protocol.GeneratorOpcode,
12
+ retryAfterMs: Schema.optional(Schema.Number),
13
+ }) {}
14
+
15
+ /** @internal */
16
+ export interface StepInfo {
17
+ readonly id: string;
18
+ readonly name: string;
19
+ readonly hash: string;
20
+ }
21
+
22
+ const toUserError = (error: unknown): typeof Protocol.UserError.Type =>
23
+ Protocol.UserError.make({
24
+ name: Predicate.hasProperty(error, "name") ? String(error.name) : "Error",
25
+ message: Predicate.hasProperty(error, "message") ? String(error.message) : String(error),
26
+ stack: Predicate.hasProperty(error, "stack") ? String(error.stack) : undefined,
27
+ });
28
+
29
+ /** @internal */
30
+ export const sleepInterrupt = (opts: { info: StepInfo; duration: string }) =>
31
+ StepInterrupt.make({ opcode: Protocol.sleep(opts.info, opts.duration) });
32
+
33
+ /** @internal */
34
+ export const waitForEventInterrupt = (opts: { info: StepInfo; event: string; timeout: string; if?: string }) =>
35
+ StepInterrupt.make({
36
+ opcode: Protocol.waitForEvent(opts.info, { event: opts.event, timeout: opts.timeout, if: opts.if }),
37
+ });
38
+
39
+ /** @internal */
40
+ export const invokeInterrupt = (opts: { info: StepInfo; functionId: string; payload: unknown; timeout: string }) =>
41
+ StepInterrupt.make({
42
+ opcode: Protocol.invokeFunction(opts.info, {
43
+ function_id: opts.functionId,
44
+ payload: opts.payload,
45
+ timeout: opts.timeout,
46
+ }),
47
+ });
48
+
49
+ /** @internal */
50
+ export const plannedInterrupt = (opts: { info: StepInfo }) =>
51
+ StepInterrupt.make({ opcode: Protocol.stepPlanned(opts.info) });
52
+
53
+ /** @internal */
54
+ export const runInterrupt = (opts: { info: StepInfo; data: unknown }) =>
55
+ StepInterrupt.make({ opcode: Protocol.stepRun(opts.info, opts.data) });
56
+
57
+ /** @internal */
58
+ export const errorInterrupt = (opts: { info: StepInfo; error: unknown; noRetry?: boolean; retryAfterMs?: number }) =>
59
+ StepInterrupt.make({
60
+ opcode: Protocol.stepError(opts.info, toUserError(opts.error), opts.noRetry),
61
+ retryAfterMs: opts.retryAfterMs,
62
+ });