Skip to content

Commit 243e885

Browse files
authored
fix(medusa): API workflow subscription (#15134)
## Summary **What** — 1) Change req.query to req.params in appropriate api routes + move /api/admin/workflows-executions/[workflow_id]/[transaction_id]/[step_id]/subscribe/route.ts to /api/admin/workflows-executions/[workflow_id]/[transaction_id]/subscribe/route.ts, because it is not possible to subscribe to individual steps, only to individual workflows. + modify API reference in documentation to mirror changes 2) Fix SSE Streaming in JS-SDK **Why** — 1) Current implementation relies on **req.query** to get the id of workflow/transaction, but it should get them from **req.params** instead. It is also impossible to subscribe to specific steps. 2) When setting up SSE streams with JS-SDK, if an event has yet to be streamed, it is impossible to abort, because nothing has returned from the JS-SDK yet. **How** — 1) Change req.query to req.params 2) fetchStream now always returns (non-null generator), so that it is possible to abort before receiving any events **Testing** — https://docs.medusajs.com/api/admin#workflows-executions_getworkflowsexecutionsworkflow_idsubscribe --- ## Examples https://docs.medusajs.com/resources/js-sdk#stream-server-sent-events ```ts const StreamTestPage = () => { const [messages, setMessages] = useState<string[]>([]) const [isStreaming, setIsStreaming] = useState(false) const [abortStream, setAbortStream] = useState<(() => void) | null>(null) const startStream = async () => { setIsStreaming(true) setMessages([]) const { stream, abort } = await sdk.client.fetchStream("/admin/stream") // Store the abort function for the abort button setAbortStream(() => abort) try { for await (const chunk of stream) { // Since the server sends plain text, convert to string const message = typeof chunk === "string" ? chunk : (chunk.data || String(chunk)) setMessages((prev) => [...prev, message.trim()]) } } catch (error) { // Don't log abort errors as they're expected when user clicks abort if (error instanceof Error && error.name !== "AbortError") { console.error("Stream error:", error) if (error.name === "HttpError") { abort() } } } finally { setIsStreaming(false) setAbortStream(null) } } const handleAbort = () => { if (abortStream) { abortStream() setIsStreaming(false) setAbortStream(null) } } return ( <Container className="p-6"> <Heading level="h1" className="mb-6"> fetchStream Example </Heading> <div className="space-y-4"> <div className="flex gap-2"> <Button onClick={startStream} disabled={isStreaming} variant="primary" > {isStreaming ? "Streaming..." : "Start Stream"} </Button> <Button onClick={handleAbort} disabled={!isStreaming} variant="secondary" > Abort Stream </Button> </div> <div className="border rounded p-4 h-64 overflow-y-auto bg-ui-bg-subtle"> {messages.length === 0 ? ( <Text className="text-ui-fg-muted">No messages yet...</Text> ) : ( messages.map((msg, index) => ( <div key={index} className="mb-2 text-sm"> {msg} </div> )) )} </div> </div> </Container> ) } ``` --- ## Checklist Please ensure the following before requesting a review: - [ X] I have added a **changeset** for this PR - Every non-breaking change should be marked as a **patch** - To add a changeset, run `yarn changeset` and follow the prompts - [ X] The changes are covered by relevant **tests** - [ X] I have verified the code works as intended locally - [ X] I have linked the related issue(s) if applicable --- ## Additional Context This is a new PR based on [13886](#13886). Unfortunately I synced the fork and deleted my commits, which then automatically closed the PR. Closes #15135 Closes #15136
1 parent 10bd6eb commit 243e885

6 files changed

Lines changed: 71 additions & 91 deletions

File tree

.changeset/hungry-numbers-sing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@medusajs/medusa": minor
3+
"@medusajs/js-sdk": minor
4+
---
5+
6+
fix(medusa,js-sdk): Refactor Workflow Subscription & Fix SSE Stream

integration-tests/http/__tests__/workflow-engine/admin/index.spec.ts

Lines changed: 48 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,16 @@ medusaIntegrationTestRunner({
9595
})
9696
})
9797

98-
describe("POST /admin/workflow-execution/[workflow_id]/steps/failure", function () {
98+
describe("POST /admin/workflows-executions/[workflow_id]/steps/failure", function () {
9999
it("should set step as failed", async () => {
100100
const stepId = 'test-step'
101101
const step = createStep({
102102
name: stepId,
103103
async: true,
104104
}, () => { })
105105

106-
const workflowId = 'test-workflow'
106+
const workflowId =
107+
"workflow-f" + Math.random().toString(36).substring(2, 15)
107108
createWorkflow({
108109
name: workflowId,
109110
retentionTime: 60,
@@ -112,7 +113,8 @@ medusaIntegrationTestRunner({
112113
return new WorkflowResponse(void 0)
113114
})
114115

115-
const transactionId = "test-transaction"
116+
const transactionId =
117+
"trx_123_f" + Math.random().toString(36).substring(2, 15)
116118
const engine = container.resolve(Modules.WORKFLOW_ENGINE) as IWorkflowEngineService
117119
await engine.run(workflowId, {
118120
transactionId
@@ -143,95 +145,58 @@ medusaIntegrationTestRunner({
143145
})
144146
})
145147

146-
describe("Workflow Orchestrator module subscribe", function () {
147-
it("should subscribe to a workflow and receive the response when it finishes", async () => {
148-
const step1 = createStep({ name: "step1" }, async () => {
149-
return new StepResponse("step1")
150-
})
151-
const step2 = createStep({ name: "step2" }, async () => {
152-
await setTimeout(1000)
153-
return new StepResponse("step2")
154-
})
155-
156-
const workflowId =
157-
"workflow" + Math.random().toString(36).substring(2, 15)
158-
createWorkflow(workflowId, function (input) {
159-
step1()
160-
step2().config({
161-
async: true,
162-
})
163-
return new WorkflowResponse("workflow")
164-
})
148+
describe("POST /admin/workflows-executions/[workflow_id]/steps/success", function () {
149+
it("should set step as successful", async () => {
150+
const stepId = 'test-step'
151+
const step = createStep({
152+
name: stepId,
153+
async: true,
154+
}, () => { })
165155

166-
const step1_1 = createStep({ name: "step1_1" }, async () => {
167-
return new StepResponse("step1_1")
168-
})
169-
const step2_1 = createStep({ name: "step2_1" }, async () => {
170-
await setTimeout(1000)
171-
return new StepResponse("step2_1")
172-
})
156+
const workflowId =
157+
"workflow_s" + Math.random().toString(36).substring(2, 15)
158+
createWorkflow({
159+
name: workflowId,
160+
retentionTime: 60,
161+
}, () => {
162+
step()
163+
return new WorkflowResponse(void 0)
164+
})
173165

174-
const workflow2Id =
175-
"workflow_2" + Math.random().toString(36).substring(2, 15)
176-
createWorkflow(workflow2Id, function (input) {
177-
step1_1()
178-
step2_1().config({
179-
async: true,
180-
})
181-
return new WorkflowResponse("workflow_2")
182-
})
166+
const transactionId =
167+
"trx_123_s" + Math.random().toString(36).substring(2, 15)
168+
const engine = container.resolve(Modules.WORKFLOW_ENGINE) as IWorkflowEngineService
169+
await engine.run(workflowId, {
170+
transactionId
171+
})
172+
let workflowDetail = (await api.get(`/admin/workflows-executions/${workflowId}/${transactionId}`, adminHeaders)).data.workflow_execution
183173

184-
const transactionId =
185-
"trx_123" + Math.random().toString(36).substring(2, 15)
186-
const transactionId2 =
187-
"trx_124" + Math.random().toString(36).substring(2, 15)
174+
expect(workflowDetail.state).toBe(TransactionState.INVOKING)
188175

189-
const onWorkflowFinishSpy = jest.fn()
176+
const setSuccessResponse = await api.post(`/admin/workflows-executions/${workflowId}/steps/success`, {
177+
transaction_id: transactionId,
178+
step_id: stepId
179+
}, adminHeaders)
190180

191-
const onWorkflowFinishPromise = new Promise<void>((resolve) => {
192-
void workflowOrcModule.subscribe({
193-
workflowId: workflowId,
194-
transactionId,
195-
subscriber: (event) => {
196-
console.log("event", event)
197-
if (event.eventType === "onFinish") {
198-
onWorkflowFinishSpy()
199-
workflowOrcModule.run(workflow2Id, {
200-
transactionId: transactionId2,
181+
expect(setSuccessResponse.status).toBe(200)
182+
expect(setSuccessResponse.data).toEqual(
183+
expect.objectContaining({
184+
success: true,
201185
})
202-
resolve()
203-
}
204-
},
205-
})
206-
})
207-
208-
const onWorkflow2FinishSpy = jest.fn()
209-
210-
const workflow2FinishPromise = new Promise<void>((resolve) => {
211-
void workflowOrcModule.subscribe({
212-
workflowId: workflow2Id,
213-
subscriber: (event) => {
214-
console.log("event", event)
215-
if (event.eventType === "onFinish") {
216-
onWorkflow2FinishSpy()
217-
resolve()
218-
}
219-
},
220-
})
221-
})
186+
)
222187

223-
workflowOrcModule.run(workflowId, {
224-
transactionId,
188+
workflowDetail = (await api.get(`/admin/workflows-executions/${workflowId}/${transactionId}`, adminHeaders)).data.workflow_execution
189+
190+
expect(workflowDetail).toEqual(
191+
expect.objectContaining({
192+
state: TransactionState.DONE,
193+
})
194+
)
225195
})
196+
})
226197

227-
await onWorkflowFinishPromise
228-
await workflow2FinishPromise
229-
230-
expect(onWorkflowFinishSpy).toHaveBeenCalledTimes(1)
231-
expect(onWorkflow2FinishSpy).toHaveBeenCalledTimes(1)
232-
})
233-
234-
it("should subscribe to a workflow and receive the response when it finishes (2)", async () => {
198+
describe("Workflow Orchestrator module subscribe", function () {
199+
it("should subscribe to a workflow and receive the response when it finishes (1)", async () => {
235200
const step1 = createStep({ name: "step1" }, async () => {
236201
return new StepResponse("step1")
237202
})

packages/core/js-sdk/src/client.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,26 @@ export class Client {
194194
const abortController = new AbortController()
195195
const abortFunc = abortController.abort.bind(abortController)
196196

197-
let res = await this.fetch_(input, {
197+
const fetchPromise = this.fetch_(input, {
198198
...init,
199199
signal: abortController.signal,
200200
headers: { ...init?.headers, accept: "text/event-stream" },
201201
})
202202

203-
if (res.ok) {
204-
return { stream: events(res, abortController.signal), abort: abortFunc }
205-
}
203+
return {
204+
stream: (async function* () {
205+
const res = await fetchPromise
206206

207-
return { stream: null, abort: abortFunc }
207+
if (!res.ok) {
208+
const error = new Error(`Stream failed with status ${res.status}`)
209+
error.name = "HttpError"
210+
throw error
211+
}
212+
213+
yield* events(res, abortController.signal)
214+
})(),
215+
abort: abortFunc
216+
}
208217
}
209218

210219
async setToken(token: string) {

packages/core/js-sdk/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,6 @@ export interface ServerSentEventMessage {
7878
}
7979

8080
export interface FetchStreamResponse {
81-
stream: AsyncGenerator<ServerSentEventMessage, void, unknown> | null
81+
stream: AsyncGenerator<ServerSentEventMessage, void, unknown>
8282
abort: () => void
8383
}

packages/medusa/src/api/admin/workflows-executions/[workflow_id]/[transaction_id]/[step_id]/subscribe/route.ts renamed to packages/medusa/src/api/admin/workflows-executions/[workflow_id]/[transaction_id]/subscribe/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const GET = async (
1414
Modules.WORKFLOW_ENGINE
1515
)
1616

17-
const { workflow_id, transaction_id } = req.query as any
17+
const { workflow_id, transaction_id } = req.params
1818

1919
const subscriberId = "__sub__" + Math.random().toString(36).substring(2, 9)
2020
res.writeHead(200, {

packages/medusa/src/api/admin/workflows-executions/[workflow_id]/subscribe/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const GET = async (
1414
Modules.WORKFLOW_ENGINE
1515
)
1616

17-
const { workflow_id } = req.query as any
17+
const { workflow_id } = req.params
1818

1919
const subscriberId = "__sub__" + Math.random().toString(36).substring(2, 9)
2020
res.writeHead(200, {

0 commit comments

Comments
 (0)