@lostgradient/weft 0.6.0 → 0.8.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 (135) hide show
  1. package/README.md +7 -5
  2. package/dist/cli/generated/operation-client.generated.d.ts +1 -0
  3. package/dist/cli-main.js +63 -63
  4. package/dist/client/event-stream-options.d.ts +58 -0
  5. package/dist/client/event-stream-options.js +0 -0
  6. package/dist/client/event-stream.d.ts +27 -44
  7. package/dist/client/event-stream.js +21 -56
  8. package/dist/client/event-tail-lifecycle.d.ts +21 -0
  9. package/dist/client/event-tail-lifecycle.js +71 -0
  10. package/dist/client/event-tail.d.ts +3 -1
  11. package/dist/client/http-client.d.ts +4 -5
  12. package/dist/client/http-client.js +4 -1
  13. package/dist/client/http-handle.d.ts +4 -5
  14. package/dist/client/http-request.d.ts +7 -0
  15. package/dist/client/index.d.ts +2 -1
  16. package/dist/client/interface.d.ts +23 -7
  17. package/dist/client/local-handles.d.ts +2 -0
  18. package/dist/client/local-handles.js +10 -1
  19. package/dist/client/local.d.ts +3 -3
  20. package/dist/client/open-event-subscription.d.ts +3 -2
  21. package/dist/client/open-event-subscription.js +28 -1
  22. package/dist/client/sse-event-stream.d.ts +27 -0
  23. package/dist/client/sse-event-stream.js +276 -0
  24. package/dist/client/start-body.d.ts +2 -2
  25. package/dist/client/start-body.js +1 -0
  26. package/dist/core/context/activity-retry-state.d.ts +6 -5
  27. package/dist/core/context/activity-retry-state.js +31 -21
  28. package/dist/core/context/durable-activity.d.ts +117 -0
  29. package/dist/core/context/durable-activity.js +79 -0
  30. package/dist/core/context/operation-request.d.ts +15 -0
  31. package/dist/core/context/parallel-operations.js +1 -0
  32. package/dist/core/context/run-operation-cached-request.d.ts +8 -0
  33. package/dist/core/context/run-operation-cached-request.js +59 -0
  34. package/dist/core/context/run-operation.d.ts +23 -5
  35. package/dist/core/context/run-operation.js +62 -47
  36. package/dist/core/engine/activity-heartbeat-tracking.d.ts +8 -17
  37. package/dist/core/engine/activity-heartbeat-tracking.js +7 -1
  38. package/dist/core/engine/activity-reconciliation.d.ts +1 -0
  39. package/dist/core/engine/activity-reconciliation.js +7 -2
  40. package/dist/core/engine/anonymous-signal-sequence.js +6 -4
  41. package/dist/core/engine/async-activity-completion.d.ts +5 -7
  42. package/dist/core/engine/async-activity-completion.js +13 -9
  43. package/dist/core/engine/bulk-operations-purge.js +2 -1
  44. package/dist/core/engine/bulk-operations.js +8 -7
  45. package/dist/core/engine/callback-checkpoint-persistence.d.ts +3 -0
  46. package/dist/core/engine/callback-checkpoint-persistence.js +25 -0
  47. package/dist/core/engine/callback-creators-bundles.js +4 -1
  48. package/dist/core/engine/checkpoint-io.d.ts +4 -1
  49. package/dist/core/engine/checkpoint-io.js +19 -10
  50. package/dist/core/engine/completed-review-storage.d.ts +2 -1
  51. package/dist/core/engine/completed-review-storage.js +4 -3
  52. package/dist/core/engine/construction.js +3 -1
  53. package/dist/core/engine/engine-internal-types.d.ts +2 -0
  54. package/dist/core/engine/errors.d.ts +6 -6
  55. package/dist/core/engine/errors.js +1 -1
  56. package/dist/core/engine/handles.js +1 -1
  57. package/dist/core/engine/index.d.ts +37 -5
  58. package/dist/core/engine/index.js +7 -1
  59. package/dist/core/engine/internals.d.ts +2 -1
  60. package/dist/core/engine/lease-manager.js +2 -2
  61. package/dist/core/engine/lifecycle/recovered-services.js +8 -1
  62. package/dist/core/engine/lifecycle/start-or-signal-create.d.ts +2 -2
  63. package/dist/core/engine/lifecycle/start-or-signal-create.js +17 -15
  64. package/dist/core/engine/lifecycle/start-or-signal-resolution.d.ts +1 -1
  65. package/dist/core/engine/lifecycle/start-or-signal-resolution.js +23 -5
  66. package/dist/core/engine/lifecycle/start-or-signal.d.ts +6 -4
  67. package/dist/core/engine/lifecycle/start-or-signal.js +21 -4
  68. package/dist/core/engine/memo-durable-activity.d.ts +11 -0
  69. package/dist/core/engine/memo-durable-activity.js +282 -0
  70. package/dist/core/engine/operations-activity.d.ts +5 -1
  71. package/dist/core/engine/operations-activity.js +19 -7
  72. package/dist/core/engine/operations-data.d.ts +4 -1
  73. package/dist/core/engine/operations-data.js +3 -3
  74. package/dist/core/engine/reviews.js +7 -4
  75. package/dist/core/engine/schedule-run.js +12 -3
  76. package/dist/core/engine/schedule-timer.js +2 -0
  77. package/dist/core/engine/storage-io.js +2 -2
  78. package/dist/core/engine/termination/complete.js +3 -3
  79. package/dist/core/json.js +1 -1
  80. package/dist/core/scheduler/scheduler-class.js +2 -1
  81. package/dist/core/start-workflow-validation.d.ts +3 -4
  82. package/dist/core/start-workflow-validation.js +1 -1
  83. package/dist/core/timeouts.d.ts +13 -1
  84. package/dist/core/timeouts.js +4 -1
  85. package/dist/core/types/constants.d.ts +16 -0
  86. package/dist/core/types/options.d.ts +60 -12
  87. package/dist/core/types/services-resolution.d.ts +49 -3
  88. package/dist/core/weft-error.d.ts +1 -1
  89. package/dist/core/weft-error.js +3 -0
  90. package/dist/index.d.ts +5 -4
  91. package/dist/index.js +7 -0
  92. package/dist/json-schema.js +1 -1
  93. package/dist/mcp/cli.js +26 -26
  94. package/dist/server/asyncapi-channels.js +82 -16
  95. package/dist/server/handler/auth-context-principal.d.ts +19 -0
  96. package/dist/server/handler/auth-context-principal.js +25 -0
  97. package/dist/server/handler/index.d.ts +2 -1
  98. package/dist/server/handler/index.js +35 -18
  99. package/dist/server/handler/route-dispatch.d.ts +12 -22
  100. package/dist/server/handler/route-dispatch.js +30 -36
  101. package/dist/server/handler/sse-route-dispatch.d.ts +25 -0
  102. package/dist/server/handler/sse-route-dispatch.js +45 -0
  103. package/dist/server/handler.js +28 -28
  104. package/dist/server/index.d.ts +8 -7
  105. package/dist/server/index.js +17 -17
  106. package/dist/server/operations/fleet-events-sse.d.ts +11 -0
  107. package/dist/server/operations/fleet-events-sse.js +164 -0
  108. package/dist/server/operations/sse-stream.d.ts +43 -0
  109. package/dist/server/operations/sse-stream.js +152 -7
  110. package/dist/server/operations/start-or-signal-workflow.d.ts +2 -0
  111. package/dist/server/operations/start-or-signal-workflow.js +25 -14
  112. package/dist/server/operations/start-workflow-options.d.ts +9 -6
  113. package/dist/server/operations/start-workflow-options.js +13 -0
  114. package/dist/server/operations/workflow-events-sse.d.ts +17 -0
  115. package/dist/server/operations/workflow-events-sse.js +200 -0
  116. package/dist/server/rest-bindings.js +12 -0
  117. package/dist/server/runtime/authentication-bridge.js +4 -0
  118. package/dist/server/runtime/cors.d.ts +1 -1
  119. package/dist/server/runtime/cors.js +6 -1
  120. package/dist/server/runtime/websocket-stream.d.ts +4 -0
  121. package/dist/server/runtime/websocket-stream.js +20 -3
  122. package/dist/server/runtime/websocket-worker.js +7 -2
  123. package/dist/server/serve-internals.d.ts +28 -0
  124. package/dist/server/serve-internals.js +4 -2
  125. package/dist/server/workflow-event-feed.d.ts +1 -0
  126. package/dist/server/workflow-event-feed.js +17 -6
  127. package/dist/service-worker/index.js +28 -28
  128. package/dist/service-worker/setup.d.ts +18 -1
  129. package/dist/service-worker/setup.js +7 -4
  130. package/dist/storage/typed-storage.d.ts +1 -1
  131. package/dist/storage/typed-storage.js +1 -1
  132. package/dist/testing/index.js +27 -27
  133. package/dist/version.d.ts +1 -1
  134. package/dist/version.js +1 -1
  135. package/package.json +1 -1
@@ -0,0 +1,200 @@
1
+ import { z } from "zod";
2
+ import { defineOperation } from "../operation-registry.js";
3
+ import { isAuthenticated } from "../principal.js";
4
+ import {
5
+ decodeCursor
6
+ } from "../workflow-event-feed.js";
7
+ import { invalidParamsFault, jsonErrorResponse } from "./operation-helpers.js";
8
+ import {
9
+ createEventEnvelopeSSEStream,
10
+ readServerSentEventsCursor,
11
+ requireServerSentEventsAccept,
12
+ shapeServerSentEventsFault,
13
+ SSE_RESPONSE_HEADERS
14
+ } from "./sse-stream.js";
15
+ const INITIAL_CURSOR = "-1", MAX_WORKFLOW_SSE_REPLAY_EVENTS = 1000, MAXIMUM_WORKFLOW_STREAMS_MESSAGE = "maximum stream connections per workflow exceeded", cursorSchema = z.string().refine((cursor) => decodeCursor(cursor) !== null, {
16
+ message: "Invalid cursor"
17
+ }), workflowEventsSseInputSchema = z.object({
18
+ workflowId: z.string().min(1),
19
+ selector: z.enum(["events", "tokens"]).default("events"),
20
+ fromCursor: cursorSchema.optional(),
21
+ lastEventId: cursorSchema.optional()
22
+ }), workflowEventsSseOutputSchema = z.custom(isAsyncIterable, "Expected async iterable workflow event stream"), workflowEventEnvelopeSchema = z.object({
23
+ kind: z.string(),
24
+ workflowId: z.string(),
25
+ selector: z.enum(["events", "tokens"]),
26
+ sequence: z.number(),
27
+ cursor: z.string(),
28
+ emittedAtMs: z.number(),
29
+ payload: z.unknown()
30
+ });
31
+ export const workflowEventsSseOperation = defineOperation({
32
+ name: "weft.workflows.events.sse",
33
+ mcpExposable: !1,
34
+ kind: "stream",
35
+ summary: "Stream workflow events as Server-Sent Events",
36
+ description: '`selector: "events"` requires `events:read`; `selector: "tokens"` requires `streams:read`. The stream replays from `fromCursor` or `Last-Event-ID`, then remains open for live events and `ping` keepalives.',
37
+ destructive: !1,
38
+ tags: ["Events"],
39
+ inputSchema: workflowEventsSseInputSchema,
40
+ outputSchema: workflowEventsSseOutputSchema,
41
+ eventSchema: workflowEventEnvelopeSchema,
42
+ parameterizedAccess: {
43
+ discriminator: "selector",
44
+ defaultValue: "events",
45
+ variants: [
46
+ {
47
+ value: "events",
48
+ access: { kind: "scoped", scopes: { kind: "anyOf", scopes: ["events:read"] } }
49
+ },
50
+ {
51
+ value: "tokens",
52
+ access: { kind: "scoped", scopes: { kind: "anyOf", scopes: ["streams:read"] } }
53
+ }
54
+ ]
55
+ },
56
+ producibleFaults: ["InvalidParams", "UnsupportedTransport"],
57
+ access: { kind: "authenticated" },
58
+ authorize: async ({ input, principal }) => {
59
+ const requiredScope = workflowSseScope(input.selector);
60
+ if (!isAuthenticated(principal))
61
+ return { allowed: !1, classification: "unauthorized", reason: "authentication required" };
62
+ if (principal.hasScope(requiredScope))
63
+ return { allowed: !0 };
64
+ return {
65
+ allowed: !1,
66
+ classification: "forbidden",
67
+ reason: `requires scope: ${requiredScope}`
68
+ };
69
+ },
70
+ discoverable: !0,
71
+ transports: { http: !0, jsonRpcHttp: !1, jsonRpcWebSocket: !1, jsonRpcStdio: !1 },
72
+ unknownKeyPolicy: { http: "strip", jsonRpc: "reject" },
73
+ invoke: async ({ input, engine }) => {
74
+ return createWorkflowEventsIterable(input, eventStreamOperationContext(engine));
75
+ }
76
+ });
77
+ function workflowSseScope(selector) {
78
+ return selector === "tokens" ? "streams:read" : "events:read";
79
+ }
80
+ function eventStreamOperationContext(engine) {
81
+ if (typeof engine !== "object" || engine === null)
82
+ return {};
83
+ return engine;
84
+ }
85
+ function unsupportedEventStreamContextFault(message) {
86
+ return {
87
+ code: "UnsupportedTransport",
88
+ message,
89
+ data: { transport: "http-rest", supported: ["http-rest"] }
90
+ };
91
+ }
92
+ function maximumWorkflowStreamsFault() {
93
+ return invalidParamsFault(MAXIMUM_WORKFLOW_STREAMS_MESSAGE);
94
+ }
95
+ function isAsyncIterable(value) {
96
+ if (typeof value !== "object" || value === null)
97
+ return !1;
98
+ return typeof value[Symbol.asyncIterator] === "function";
99
+ }
100
+ function isClosableWorkflowEventsIterable(value) {
101
+ return "close" in value && typeof value.close === "function";
102
+ }
103
+ function isReplayAwareWorkflowEventsIterable(value) {
104
+ return isClosableWorkflowEventsIterable(value) && "replayComplete" in value && value.replayComplete instanceof Promise;
105
+ }
106
+ function createWorkflowEventsIterable(input, context) {
107
+ const feed = context.workflowEventFeed;
108
+ if (feed === void 0)
109
+ throw unsupportedEventStreamContextFault("workflow event SSE requires a workflow event feed");
110
+ const lease = context.acquireWorkflowStreamConnection?.(input.workflowId);
111
+ if (lease === null)
112
+ throw maximumWorkflowStreamsFault();
113
+ const controller = new AbortController, replayComplete = Promise.withResolvers();
114
+ let closed = !1;
115
+ const close = async () => {
116
+ if (closed)
117
+ return;
118
+ closed = !0;
119
+ controller.abort();
120
+ lease?.release();
121
+ }, fromCursor = input.lastEventId ?? input.fromCursor ?? INITIAL_CURSOR, subscribeOptions = {
122
+ workflowId: input.workflowId,
123
+ selector: input.selector,
124
+ fromCursor,
125
+ signal: controller.signal,
126
+ replayLimit: MAX_WORKFLOW_SSE_REPLAY_EVENTS,
127
+ onReplayComplete: () => replayComplete.resolve(),
128
+ createReplayLimitError: (count, limit) => invalidParamsFault(`Workflow event replay window is ${count} events; maximum is ${limit}. Supply a more recent fromCursor.`)
129
+ };
130
+ let source;
131
+ try {
132
+ source = feed.subscribe(subscribeOptions);
133
+ } catch (error) {
134
+ close();
135
+ throw error;
136
+ }
137
+ return {
138
+ async* [Symbol.asyncIterator]() {
139
+ try {
140
+ for await (const envelope of source)
141
+ yield envelope;
142
+ } finally {
143
+ await close();
144
+ }
145
+ },
146
+ replayComplete: replayComplete.promise,
147
+ close
148
+ };
149
+ }
150
+ function shapeWorkflowEventsSseSuccess(output, request) {
151
+ const close = isClosableWorkflowEventsIterable(output) ? () => output.close() : async () => {
152
+ return;
153
+ };
154
+ return new Response(createEventEnvelopeSSEStream({
155
+ iterable: output,
156
+ close,
157
+ ...isReplayAwareWorkflowEventsIterable(output) ? { ready: output.replayComplete } : {},
158
+ signal: request.signal
159
+ }), {
160
+ status: 200,
161
+ headers: SSE_RESPONSE_HEADERS
162
+ });
163
+ }
164
+ function shapeWorkflowEventsSseFault(fault) {
165
+ if (fault.code === "InvalidParams" && fault.message === MAXIMUM_WORKFLOW_STREAMS_MESSAGE)
166
+ return jsonErrorResponse(fault.message, 429);
167
+ return shapeServerSentEventsFault(fault);
168
+ }
169
+ function readSelector(request) {
170
+ const value = new URL(request.url).searchParams.get("selector");
171
+ if (value === null)
172
+ return;
173
+ return value;
174
+ }
175
+ export const workflowEventsSseRestBinding = {
176
+ method: "GET",
177
+ path: "/v1/workflows/:id/events/sse",
178
+ pathParamNames: ["id"],
179
+ operationName: "weft.workflows.events.sse",
180
+ transportKind: "sse",
181
+ inputSources: {
182
+ workflowId: { kind: "path", pathParam: "id" },
183
+ selector: { kind: "query", queryParam: "selector" },
184
+ fromCursor: { kind: "query", queryParam: "fromCursor" },
185
+ lastEventId: { kind: "header", headerName: "Last-Event-ID" }
186
+ },
187
+ extractInput: async (request, pathParams) => {
188
+ requireServerSentEventsAccept(request);
189
+ const url = new URL(request.url), lastEventId = readServerSentEventsCursor(request.headers.get("Last-Event-ID")), fromCursor = lastEventId === void 0 ? readServerSentEventsCursor(url.searchParams.get("fromCursor")) : void 0, selector = readSelector(request);
190
+ return {
191
+ workflowId: pathParams.id ?? "",
192
+ ...selector === void 0 ? {} : { selector },
193
+ ...fromCursor === void 0 ? {} : { fromCursor },
194
+ ...lastEventId === void 0 ? {} : { lastEventId }
195
+ };
196
+ },
197
+ success: { kind: "streaming", mediaType: "text/event-stream" },
198
+ shapeSuccess: shapeWorkflowEventsSseSuccess,
199
+ shapeFault: shapeWorkflowEventsSseFault
200
+ };
@@ -45,6 +45,10 @@ import {
45
45
  createScheduleOperation,
46
46
  createScheduleRestBinding
47
47
  } from "./operations/create-schedule.js";
48
+ import {
49
+ fleetEventsSseOperation,
50
+ fleetEventsSseRestBinding
51
+ } from "./operations/fleet-events-sse.js";
48
52
  import { fleetEventsSubscriptionOperation } from "./operations/fleet-events-subscription.js";
49
53
  import { forkWorkflowOperation, forkWorkflowRestBinding } from "./operations/fork-workflow.js";
50
54
  import {
@@ -203,6 +207,10 @@ import {
203
207
  drainDeploymentOperation,
204
208
  drainWorkerOperation
205
209
  } from "./operations/worker-drain.js";
210
+ import {
211
+ workflowEventsSseOperation,
212
+ workflowEventsSseRestBinding
213
+ } from "./operations/workflow-events-sse.js";
206
214
  import { workflowEventsSubscriptionOperation } from "./operations/workflow-events-subscription.js";
207
215
  export const REST_BINDINGS = [
208
216
  startWorkflowRestBinding,
@@ -250,6 +258,8 @@ export const REST_BINDINGS = [
250
258
  resumeScheduleRestBinding,
251
259
  getStreamChunksRestBinding,
252
260
  streamWorkflowSseRestBinding,
261
+ workflowEventsSseRestBinding,
262
+ fleetEventsSseRestBinding,
253
263
  getTaskDiagnosticsRestBinding,
254
264
  clearTaskDeadLetterRestBinding,
255
265
  listSchedulesRestBinding,
@@ -376,6 +386,8 @@ export function createLiveOperationRegistry(options) {
376
386
  resumeScheduleOperation,
377
387
  getStreamChunksOperation,
378
388
  streamWorkflowSseOperation,
389
+ workflowEventsSseOperation,
390
+ fleetEventsSseOperation,
379
391
  workflowEventsSubscriptionOperation,
380
392
  fleetEventsSubscriptionOperation,
381
393
  buildTaskDiagnosticsOperationForRegistry(resolved),
@@ -15,6 +15,7 @@ import { gateRequest } from "./request-gate.js";
15
15
  import { handleTaskPollRequest, handleTaskResultRequest } from "./task-polling.js";
16
16
  import { reassignOrExpireTask } from "./task-reconciliation.js";
17
17
  import {
18
+ acquireWorkflowStreamConnection,
18
19
  addStreamSocket,
19
20
  addWatchSocket,
20
21
  removeStreamSocket,
@@ -96,6 +97,9 @@ function buildHandlerOptions(context, options, authentication) {
96
97
  ...options.maxRequestBodyBytes !== void 0 ? { maxRequestBodyBytes: options.maxRequestBodyBytes } : {},
97
98
  operationRegistry: context.liveOperationRegistry,
98
99
  restBindings: context.liveRestBindings,
100
+ workflowEventFeed: context.workflowEventFeed,
101
+ fleetEventFeed: context.fleetEventFeed,
102
+ acquireWorkflowStreamConnection: (workflowId) => acquireWorkflowStreamConnection(context, workflowId),
99
103
  supportedAuthenticationSchemes: context.supportedAuthenticationSchemes
100
104
  };
101
105
  }
@@ -63,7 +63,7 @@ export interface CorsOptions {
63
63
  readonly allowedOrigins?: ReadonlyArray<string> | ((origin: string) => boolean);
64
64
  /** Methods advertised in preflight responses. Defaults to the common verbs plus `OPTIONS`. */
65
65
  readonly allowedMethods?: ReadonlyArray<string>;
66
- /** Request headers a client may send. Defaults to `Authorization, Content-Type`. */
66
+ /** Request headers a client may send. Defaults to `Authorization, Content-Type, Last-Event-ID, Cache-Control`. */
67
67
  readonly allowedHeaders?: ReadonlyArray<string>;
68
68
  /** Response headers exposed to client scripts via `Access-Control-Expose-Headers`. */
69
69
  readonly exposedHeaders?: ReadonlyArray<string>;
@@ -1,4 +1,9 @@
1
- const DEFAULT_ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], DEFAULT_ALLOWED_HEADERS = ["Authorization", "Content-Type"], DEFAULT_MAX_AGE_SECONDS = 600, WILDCARD_ORIGIN = "*";
1
+ const DEFAULT_ALLOWED_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], DEFAULT_ALLOWED_HEADERS = [
2
+ "Authorization",
3
+ "Content-Type",
4
+ "Last-Event-ID",
5
+ "Cache-Control"
6
+ ], DEFAULT_MAX_AGE_SECONDS = 600, WILDCARD_ORIGIN = "*";
2
7
  export function canonicalizeOrigin(value) {
3
8
  const trimmed = value.trim();
4
9
  if (trimmed === "" || trimmed === "null")
@@ -8,6 +8,9 @@ import type { ServerContext } from './context.ts';
8
8
  * workflow cannot consume unbounded server sockets.
9
9
  */
10
10
  export declare const DEFAULT_MAX_STREAM_CONNECTIONS_PER_WORKFLOW = 100;
11
+ export type WorkflowStreamConnectionLease = {
12
+ release(): void;
13
+ };
11
14
  export declare function sendStreamMessage(ws: ServerWebSocket<WebSocketData>, sequence: number, message: string): void;
12
15
  export declare function sendWatchMessage(ws: ServerWebSocket<WebSocketData>, sequence: number, message: string): void;
13
16
  export declare function getHighestStoredStreamSequence(engine: Engine, workflowId: string, key: string): Promise<number>;
@@ -17,6 +20,7 @@ export declare function addWatchSocket(context: ServerContext, workflowId: strin
17
20
  export declare function removeStreamSocket(context: ServerContext, ws: ServerWebSocket<WebSocketData>): void;
18
21
  export declare function removeWatchSocket(context: ServerContext, ws: ServerWebSocket<WebSocketData>): void;
19
22
  export declare function removeWorkflowStreamConnection(context: ServerContext, ws: ServerWebSocket<WebSocketData>): void;
23
+ export declare function acquireWorkflowStreamConnection(context: ServerContext, workflowId: string): WorkflowStreamConnectionLease | null;
20
24
  export declare function flushPendingStreamMessages(_context: ServerContext, ws: ServerWebSocket<WebSocketData>): void;
21
25
  export declare function flushPendingWatchMessages(_context: ServerContext, ws: ServerWebSocket<WebSocketData>): void;
22
26
  export declare function publishTokenMessage(context: ServerContext, workflowId: string, sequence: number, message: string): void;
@@ -86,6 +86,24 @@ export function removeWorkflowStreamConnection(context, ws) {
86
86
  if (!workflowId || !ws.data.workflowStreamConnectionAccepted)
87
87
  return;
88
88
  ws.data.workflowStreamConnectionAccepted = !1;
89
+ releaseWorkflowStreamConnection(context, workflowId);
90
+ }
91
+ export function acquireWorkflowStreamConnection(context, workflowId) {
92
+ const currentCount = context.workflowStreamConnectionCounts.get(workflowId), maxConnections = context.maxStreamConnectionsPerWorkflow;
93
+ if ((currentCount ?? 0) >= maxConnections)
94
+ return null;
95
+ context.workflowStreamConnectionCounts.set(workflowId, (currentCount ?? 0) + 1);
96
+ let released = !1;
97
+ return {
98
+ release() {
99
+ if (released)
100
+ return;
101
+ released = !0;
102
+ releaseWorkflowStreamConnection(context, workflowId);
103
+ }
104
+ };
105
+ }
106
+ function releaseWorkflowStreamConnection(context, workflowId) {
89
107
  const currentCount = context.workflowStreamConnectionCounts.get(workflowId);
90
108
  if (currentCount === void 0 || currentCount <= 1) {
91
109
  context.workflowStreamConnectionCounts.delete(workflowId);
@@ -94,12 +112,11 @@ export function removeWorkflowStreamConnection(context, ws) {
94
112
  context.workflowStreamConnectionCounts.set(workflowId, currentCount - 1);
95
113
  }
96
114
  function addWorkflowStreamConnection(context, workflowId, ws) {
97
- const currentCount = context.workflowStreamConnectionCounts.get(workflowId) ?? 0, maxConnections = context.maxStreamConnectionsPerWorkflow;
98
- if (currentCount >= maxConnections) {
115
+ const maxConnections = context.maxStreamConnectionsPerWorkflow;
116
+ if (acquireWorkflowStreamConnection(context, workflowId) === null) {
99
117
  ws.close(STREAM_CONNECTION_POLICY_CLOSE_CODE, `maximum stream connections per workflow (${maxConnections}) exceeded`);
100
118
  return !1;
101
119
  }
102
- context.workflowStreamConnectionCounts.set(workflowId, currentCount + 1);
103
120
  ws.data.workflowStreamConnectionAccepted = !0;
104
121
  return !0;
105
122
  }
@@ -74,11 +74,16 @@ function registerWorker(context, options, ws, message) {
74
74
  rejectRegistration(ws, "invalid_registration", "Worker registration requires the workers:write scope", message.protocolVersion);
75
75
  return;
76
76
  }
77
- const rawConcurrency = message.concurrency ?? DEFAULT_WORKER_CONCURRENCY, clampedConcurrency = Math.min(Math.max(1, Math.floor(rawConcurrency)), MAX_WORKER_CONCURRENCY), queue = ws.data.queue ?? "default", pendingRequeue = context.pendingWorkerRequeues.get(message.workerId);
78
- if (pendingRequeue !== void 0) {
77
+ const rawConcurrency = message.concurrency ?? DEFAULT_WORKER_CONCURRENCY, clampedConcurrency = Math.min(Math.max(1, Math.floor(rawConcurrency)), MAX_WORKER_CONCURRENCY), queue = ws.data.queue ?? "default", pendingRequeue = context.pendingWorkerRequeues.get(message.workerId), isGracePeriodReconnect = pendingRequeue !== void 0;
78
+ if (isGracePeriodReconnect) {
79
79
  clearTimeout(pendingRequeue);
80
80
  context.pendingWorkerRequeues.delete(message.workerId);
81
81
  }
82
+ const existingSocket = context.workerSockets.get(message.workerId);
83
+ if (!isGracePeriodReconnect && existingSocket !== void 0 && existingSocket !== ws) {
84
+ rejectRegistration(ws, "invalid_registration", "workerId is already registered to an active connection", message.protocolVersion);
85
+ return;
86
+ }
82
87
  ws.data.workerId = message.workerId;
83
88
  ws.data.workerRegistered = !0;
84
89
  ws.data.workerProtocolVersion = message.protocolVersion;
@@ -11,6 +11,27 @@ import type { WebSocketData } from './json-rpc-websocket-runtime.ts';
11
11
  import { createServerWebSocketHandlers } from './runtime/authentication-bridge.ts';
12
12
  import type { ServerContext } from './runtime/context.ts';
13
13
  import { type EventBroadcastingHandle } from './runtime/event-broadcasting.ts';
14
+ /**
15
+ * Hard ceiling on the raw WebSocket frame size for every connection (worker
16
+ * stream, `/watch`, token `/stream`, and JSON-RPC). Bun's default is 16 MiB;
17
+ * this caps the frame at the transport layer before any JSON parse, so a
18
+ * malicious peer cannot force a 16 MiB parse per message. A bounded 4 MiB parse
19
+ * is not a CPU-burn, so this constant ceiling fully closes the DoS on its own.
20
+ *
21
+ * This is deliberately NOT derived from `payloadSize.maxBytes`. That option is
22
+ * an application-level admission policy measured on the codec-encoded (msgpack)
23
+ * byte length of the bare value, whereas `maxPayloadLength` bounds the raw
24
+ * UTF-8 JSON frame (envelope plus JSON-serialized value) — different units.
25
+ * Tightening the frame limit down to a smaller `payloadSize.maxBytes` would
26
+ * reject legitimate frames whose value is within the admission cap (JSON and
27
+ * envelope overhead inflate the frame past the msgpack value size) with an
28
+ * opaque transport close instead of a clean `PayloadSizeExceededError`, across
29
+ * every shared WebSocket endpoint — for no additional DoS protection. Value
30
+ * size stays enforced by the post-parse admission check.
31
+ *
32
+ * @internal Exported only for test assertions.
33
+ */
34
+ export declare const WEBSOCKET_MAX_PAYLOAD_BYTES: number;
14
35
  /**
15
36
  * @internal
16
37
  *
@@ -77,6 +98,13 @@ export declare function cleanupWorkflowIndex(context: ServerContext, operationId
77
98
  /**
78
99
  * Assembles the `Bun.serve()` options object. Separating this avoids a
79
100
  * conditional spread (`...(tlsOptions ? { tls } : {})`) inside `serve()`.
101
+ *
102
+ * Sets a constant `maxPayloadLength` of `WEBSOCKET_MAX_PAYLOAD_BYTES` (4 MiB)
103
+ * so Bun rejects oversized frames at the transport layer before any JSON parse
104
+ * occurs. The cap is intentionally a fixed transport-safety ceiling, not
105
+ * derived from `payloadSize.maxBytes` (see that constant's docs for why mixing
106
+ * the raw-frame limit with the application value-size policy would cause false
107
+ * rejections in the wrong unit).
80
108
  */
81
109
  export declare function buildBunServeConfig(port: number, hostname: string, development: boolean, routes: Bun.Serve.Routes<WebSocketData, string>, tlsOptions: ReturnType<typeof buildTLSOptions>, fetchHandler: (request: Request) => Promise<Response | undefined>, websocketCallbacks: ReturnType<typeof createServerWebSocketHandlers>): Parameters<typeof Bun.serve<WebSocketData>>[0];
82
110
  /**
@@ -30,7 +30,9 @@ import { DEFAULT_MAX_STREAM_CONNECTIONS_PER_WORKFLOW } from "./runtime/websocket
30
30
  import { isInflightRecord, withRetry } from "./runtime/websocket-worker.js";
31
31
  import { TaskQueue } from "./task-queue.js";
32
32
  import { createWorkflowEventFeed } from "./workflow-event-feed.js";
33
- const RECONCILIATION_MULTIPLIER = 12, DEFAULT_WORKER_RECONNECT_GRACE_PERIOD_MS = 2000, MAX_WORKER_RECONNECT_GRACE_PERIOD_MS = 5000, AUTHENTICATION_REQUIRED_ENVIRONMENT_VARIABLE = "WEFT_SERVER_AUTHENTICATION_REQUIRED";
33
+ const RECONCILIATION_MULTIPLIER = 12, DEFAULT_WORKER_RECONNECT_GRACE_PERIOD_MS = 2000, MAX_WORKER_RECONNECT_GRACE_PERIOD_MS = 5000;
34
+ export const WEBSOCKET_MAX_PAYLOAD_BYTES = 4194304;
35
+ const AUTHENTICATION_REQUIRED_ENVIRONMENT_VARIABLE = "WEFT_SERVER_AUTHENTICATION_REQUIRED";
34
36
  export const NO_AUTHENTICATION_WARNING = '[weft] WARNING: server started with NO authentication; all non-public operations are publicly accessible. Configure serve({ auth }) to lock down, or set unauthenticatedAccess: "reject" in production to fail closed.', MCP_ORIGIN_CONFIGURATION_WARNING = '[weft] WARNING: MCP HTTP transport is enabled without publicOrigin or trustedHosts. Cross-origin /mcp requests are rejected, and discovery routes that emit absolute URLs return 503. Configure serve({ publicOrigin: "https://api.example.com" }) or serve({ trustedHosts: ["api.example.com"] }) before exposing the server through a browser or reverse proxy.';
35
37
  const NO_AUTHENTICATION_REJECT_ERROR = '[weft] Refusing to start server with no authentication. Configure serve({ auth }) or set unauthenticatedAccess: "allow" only for trusted local development.', NO_AUTHENTICATION_ENVIRONMENT_ERROR = "[weft] Refusing to start server with no authentication because WEFT_SERVER_AUTHENTICATION_REQUIRED requires authentication. Configure serve({ auth }) or unset WEFT_SERVER_AUTHENTICATION_REQUIRED only for trusted local development.", TRUTHY_AUTHENTICATION_REQUIREMENT_VALUES = new Set(["1", "true", "yes", "on"]), FALSY_AUTHENTICATION_REQUIREMENT_VALUES = new Set(["0", "false", "no", "off"]);
36
38
  export function clampWorkerReconnectGracePeriod(value) {
@@ -149,7 +151,7 @@ export function buildBunServeConfig(port, hostname, development, routes, tlsOpti
149
151
  development,
150
152
  routes,
151
153
  fetch: fetchHandler,
152
- websocket: websocketCallbacks
154
+ websocket: { ...websocketCallbacks, maxPayloadLength: WEBSOCKET_MAX_PAYLOAD_BYTES }
153
155
  };
154
156
  if (tlsOptions)
155
157
  config.tls = tlsOptions;
@@ -72,6 +72,7 @@ export type ReplayLiveSubscribeOptions<TEnvelope extends SequencedEventEnvelope>
72
72
  filterEnvelope?: (envelope: TEnvelope) => boolean;
73
73
  countReplayEnvelope?: (envelope: TEnvelope) => boolean;
74
74
  createReplayLimitError?: (count: number, limit: number) => unknown;
75
+ onReplayComplete?: () => void;
75
76
  };
76
77
  export declare class ReplayWindowExceededError extends Error {
77
78
  readonly count: number;
@@ -44,7 +44,7 @@ export function createReplayLiveFeed(backend, options) {
44
44
  }
45
45
  function subscribe(args) {
46
46
  const buffer = [];
47
- let bufferOverflowed = !1, waker = null;
47
+ let bufferOverflowed = !1, waker = null, cleanedUp = !1;
48
48
  const signal = args?.signal, fromCursor = args?.fromCursor, requestedAfter = fromCursor !== void 0 ? decodeCursorOrThrow(fromCursor) : -1, unsubscribe = backend.subscribeLive((envelope) => {
49
49
  if (!shouldDeliverEnvelope(envelope, args))
50
50
  return;
@@ -57,14 +57,23 @@ export function createReplayLiveFeed(backend, options) {
57
57
  waker = null;
58
58
  fire();
59
59
  }
60
- }), onAbort = () => {
60
+ }), wake = () => {
61
61
  if (waker) {
62
62
  const fire = waker;
63
63
  waker = null;
64
64
  fire();
65
65
  }
66
- };
67
- signal?.addEventListener("abort", onAbort);
66
+ }, cleanup = () => {
67
+ if (cleanedUp)
68
+ return;
69
+ cleanedUp = !0;
70
+ signal?.removeEventListener("abort", onAbort);
71
+ unsubscribe();
72
+ wake();
73
+ }, onAbort = () => cleanup();
74
+ signal?.addEventListener("abort", onAbort, { once: !0 });
75
+ if (signal?.aborted)
76
+ cleanup();
68
77
  async function* generator() {
69
78
  try {
70
79
  if (signal?.aborted)
@@ -73,12 +82,12 @@ export function createReplayLiveFeed(backend, options) {
73
82
  yield* replayUpTo(backend, requestedAfter, snapshot, signal, args);
74
83
  if (signal?.aborted)
75
84
  return;
85
+ args?.onReplayComplete?.();
76
86
  yield* drainLive(buffer, snapshot, signal, () => bufferOverflowed, (w) => {
77
87
  waker = w;
78
88
  });
79
89
  } finally {
80
- signal?.removeEventListener("abort", onAbort);
81
- unsubscribe();
90
+ cleanup();
82
91
  }
83
92
  }
84
93
  return generator();
@@ -119,6 +128,8 @@ export function createWorkflowEventFeed(backend, options) {
119
128
  subscribeOptions.countReplayEnvelope = args.countReplayEnvelope;
120
129
  if (args.createReplayLimitError !== void 0)
121
130
  subscribeOptions.createReplayLimitError = args.createReplayLimitError;
131
+ if (args.onReplayComplete !== void 0)
132
+ subscribeOptions.onReplayComplete = args.onReplayComplete;
122
133
  return createScopedFeed(args.workflowId, args.selector).subscribe(subscribeOptions);
123
134
  }
124
135
  return {