Skip to content

fix(medusa): API workflow subscription - #15134

Merged
kodiakhq[bot] merged 10 commits into
medusajs:developfrom
v0eak:develop
Apr 30, 2026
Merged

fix(medusa): API workflow subscription#15134
kodiakhq[bot] merged 10 commits into
medusajs:developfrom
v0eak:develop

Conversation

@v0eak

@v0eak v0eak commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

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
  1. 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

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.
Unfortunately I synced the fork and deleted my commits, which then automatically closed the PR.

Closes #15135
Closes #15136

@v0eak
v0eak requested review from a team as code owners April 17, 2026 07:31
@changeset-bot

changeset-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2b92733

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 78 packages
Name Type
@medusajs/medusa Major
@medusajs/js-sdk Major
@medusajs/test-utils Major
@medusajs/medusa-oas-cli Major
integration-tests-http Patch
@medusajs/draft-order Major
@medusajs/dashboard Major
@medusajs/analytics Major
@medusajs/api-key Major
@medusajs/auth Major
@medusajs/caching Major
@medusajs/cart Major
@medusajs/currency Major
@medusajs/customer Major
@medusajs/file Major
@medusajs/fulfillment Major
@medusajs/index Major
@medusajs/inventory Major
@medusajs/link-modules Major
@medusajs/locking Major
@medusajs/notification Major
@medusajs/order Major
@medusajs/payment Major
@medusajs/pricing Major
@medusajs/product Major
@medusajs/promotion Major
@medusajs/rbac Major
@medusajs/region Major
@medusajs/sales-channel Major
@medusajs/settings Major
@medusajs/stock-location Major
@medusajs/store Major
@medusajs/tax Major
@medusajs/translation Major
@medusajs/user Major
@medusajs/workflow-engine-inmemory Major
@medusajs/workflow-engine-redis Major
@medusajs/loyalty-plugin Major
@medusajs/oas-github-ci Major
@medusajs/admin-bundler Major
@medusajs/cache-inmemory Major
@medusajs/cache-redis Major
@medusajs/event-bus-local Major
@medusajs/event-bus-redis Major
@medusajs/analytics-local Major
@medusajs/analytics-posthog Major
@medusajs/auth-emailpass Major
@medusajs/auth-github Major
@medusajs/auth-google Major
@medusajs/caching-redis Major
@medusajs/file-local Major
@medusajs/file-s3 Major
@medusajs/fulfillment-manual Major
@medusajs/locking-postgres Major
@medusajs/locking-redis Major
@medusajs/notification-local Major
@medusajs/notification-sendgrid Major
@medusajs/payment-stripe Major
@medusajs/core-flows Major
@medusajs/framework Major
@medusajs/modules-sdk Major
@medusajs/orchestration Major
@medusajs/types Major
@medusajs/utils Major
@medusajs/workflows-sdk Major
@medusajs/http-types-generator Major
@medusajs/cli Major
@medusajs/deps Major
@medusajs/telemetry Major
@medusajs/admin-sdk Major
@medusajs/admin-shared Major
@medusajs/admin-vite-plugin Major
@medusajs/icons Major
@medusajs/toolbox Major
@medusajs/ui-preset Major
create-medusa-app Major
medusa-dev-cli Major
@medusajs/ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Apr 17, 2026

Copy link
Copy Markdown

@v0eak is attempting to deploy a commit to the medusajs Team on Vercel.

A member of the Team first needs to authorize it.

@medusa-os-bot

medusa-os-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

Thank you for your contribution, @v0eak!

After reviewing this PR, we need a few things addressed before we can move forward:

Required changes:

  • Fix the wrong spy called in workflow2FinishPromiseonWorkflowFinishSpy() is called instead of onWorkflow2FinishSpy(), which will cause both assertions to fail (onWorkflowFinishSpy called 2× instead of 1×, onWorkflow2FinishSpy called 0× instead of 1×)

Potential Bugs:

⚠️ integration-tests/http/__tests__/workflow-engine/admin/index.spec.ts — Wrong spy called in workflow2FinishPromise

if (event.event_type === "onFinish") {
  onWorkflowFinishSpy()  // should be onWorkflow2FinishSpy()
  resolve()
}

This is a copy-paste error from the onWorkflowFinishPromise block. The test assertions on lines 317–318 check onWorkflowFinishSpy (called once) and onWorkflow2FinishSpy (called once), so both will fail: workflow1's spy gets incremented twice, workflow2's spy never gets incremented.


Notes (non-blocking):

  • Dead code in workflow2FinishPromise: The void workflowOrcModule.subscribe({ workflowId: workflow2Id, ... }) block that remains after the while (true) loop is unreachable during normal execution — the loop runs until the SSE stream closes, at which point subscribing to an already-completed workflow is a no-op. Consider removing it.

  • Race condition risk: The SSE subscriptions are established asynchronously inside the Promise callbacks (via await api.get(...)), while workflowOrcModule.run(workflowId, ...) is called synchronously on the next outer line. If the workflow steps complete before the SSE connection is established, the onFinish event may be missed. The original test avoided this by using synchronous module subscriptions. Consider establishing the SSE connection and confirming a response before triggering the workflow run.

  • Misleading variable name in client.ts: const fetchPromise = await this.fetch_(...) — because of the await, fetchPromise holds an already-resolved Response, not a Promise. Renaming it to res or response would match the intent and avoid confusion.

  • Auto-generated files edited directly: www/apps/api-reference/specs/admin/openapi.full.yaml, openapi.yaml, and the paths YAML files are under the api-reference/specs directory which is auto-generated per our contribution guidelines. Manual edits there may not persist after the next generation run. Similarly, www/utils/generated/oas-output/ is auto-generated. Please check with the team on the correct generation workflow for these files.

Triggered by: new PR opened

@medusa-os-bot

medusa-os-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

Thank you for updating the PR description, @v0eak!

The previously required change is still outstanding:

Required changes:

  • Fix the wrong spy called in workflow2FinishPromiseonWorkflowFinishSpy() is still called instead of onWorkflow2FinishSpy(), causing the assertions on lines 317–318 to fail

Potential Bugs:

⚠️ integration-tests/http/__tests__/workflow-engine/admin/index.spec.ts — Wrong spy in workflow2FinishPromise (copy-paste error, still unresolved)

if (event.event_type === "onFinish") {
  onWorkflowFinishSpy()  // should be onWorkflow2FinishSpy()
  resolve()
}

onWorkflow2FinishSpy is never incremented, so expect(onWorkflow2FinishSpy).toHaveBeenCalledTimes(1) will fail, and onWorkflowFinishSpy will be called twice, breaking expect(onWorkflowFinishSpy).toHaveBeenCalledTimes(1).


Notes (non-blocking):

  • Dead code still present: The void workflowOrcModule.subscribe({ workflowId: workflow2Id, ... }) block after the while (true) loop in workflow2FinishPromise is unreachable — the loop runs until the SSE stream closes. Consider removing it.
  • Misleading variable name in client.ts: const fetchPromise = await this.fetch_(...) — because of the await, fetchPromise holds a resolved Response, not a Promise. Renaming to res or response would match the intent.

Triggered by: PR description updated

@medusa-os-bot

medusa-os-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

Thank you for your contribution, @v0eak! 🎉

After an initial review, this PR looks good to us. Here's a summary:

✅ PR template is complete
✅ Linked to verified issues (#15135, #15136)
✅ Follows contribution guidelines
✅ Tests included
✅ Changeset included with correct bump type

The previously required fix (wrong spy in workflow2FinishPromise) has been resolved — onWorkflow2FinishSpy() is now correctly called. The dead code after the while (true) loop is also gone.

A team member will do a final review before this is merged. We appreciate your patience!


Notes (non-blocking):

  • Misleading variable name in client.ts: const fetchPromise = await this.fetch_(...) — since the value is already await-ed, fetchPromise holds a Response, not a Promise. Renaming to res or response would match the intent.
  • Race condition risk in the subscription test: The SSE connections are established asynchronously inside the Promise callbacks, while workflowOrcModule.run(workflowId, ...) is called synchronously immediately after. If the workflow completes before the SSE connection is open, the onFinish event may be missed. Consider waiting for a confirmed SSE response before triggering the workflow run.
  • Auto-generated files edited directly: www/apps/api-reference/specs/admin/ and www/utils/generated/oas-output/ are auto-generated directories. Manual edits there may be overwritten on the next generation run — please confirm with the team on the correct workflow for updating these.

Triggered by: new commit pushed

@medusa-os-bot

medusa-os-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

Thank you for the continued updates, @v0eak!

After reviewing the latest commit, a new issue has been introduced that needs to be addressed before we can restore initial approval.

Required changes:

  • Fix the test isolation issue in the new steps/success test — use random transactionId and workflowId values (as the subscribe tests do) to avoid conflicts with the existing steps/failure test

Potential Bugs:

⚠️ integration-tests/http/__tests__/workflow-engine/admin/index.spec.ts — Hardcoded IDs conflict between steps/failure and steps/success tests

// In the new success test (same values as the failure test above it):
const workflowId = 'test-workflow'
const transactionId = 'test-transaction'

The failure test also uses workflowId = 'test-workflow' and transactionId = "test-transaction". There is no cleanup between tests — beforeEach only creates an admin user. If the failure test runs first, it leaves a workflow execution for transactionId = "test-transaction" in REVERTED state. When the success test then calls engine.run(workflowId, { transactionId }) with the same IDs, the engine may return or resume the existing reverted execution, causing the expect(workflowDetail.state).toBe(TransactionState.INVOKING) assertion to fail. Fix by generating random IDs, as the subscribe tests do:

const workflowId = 'test-workflow-success-' + Math.random().toString(36).substring(2, 15)
const transactionId = 'test-transaction-' + Math.random().toString(36).substring(2, 15)

Notes (non-blocking):

  • Misleading variable name in client.ts: const fetchPromise = await this.fetch_(...) — since the value is already await-ed, fetchPromise holds a Response, not a Promise. Renaming to res or response would match the intent.

Triggered by: new commit pushed

@v0eak

v0eak commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

I have removed the http test because internally the test runner uses axios, and its not setup to allow for SSE. It would require changes to the medusa-test-runner (maybe reason why the test was missing in the first place?)

@medusa-os-bot

medusa-os-bot Bot commented Apr 17, 2026

Copy link
Copy Markdown

Thank you for addressing all the required changes, @v0eak! 🎉

After reviewing the latest commit, this PR looks good to us. Here's a summary:

✅ PR template is complete
✅ Linked to verified issues (#15135, #15136)
✅ Changeset included with correct bump type (minor — breaking URL path change)
✅ Both required fixes resolved: random IDs in the steps/success test, and the wrong spy from earlier rounds
req.queryreq.params bug fixed in both subscribe routes
FetchStreamResponse.stream correctly changed from nullable to always-non-null

A team member will do a final review before this is merged. We appreciate your patience!


Notes (non-blocking):

  • Misleading variable name in packages/core/js-sdk/src/client.ts: const fetchPromise = await this.fetch_(...) — since the value is already await-ed, fetchPromise holds a Response, not a Promise. The inner const res = await fetchPromise just re-awaits an already-resolved value, which works but is confusing. Renaming to res or response would match the intent (this has been noted in previous reviews).
  • No HTTP-level test for subscribe route: The SSE-based API test was removed because the test runner uses axios which lacks SSE support — understood. As a result, the req.queryreq.params fix in the subscribe routes has no HTTP integration test. This is an existing limitation, but worth tracking for a follow-up once the test runner supports SSE.
  • Auto-generated spec files edited directly: Changes under www/apps/api-reference/specs/admin/ and www/utils/generated/oas-output/ may be overwritten on the next generation run. Please confirm with the team on the correct workflow for keeping these in sync.

Triggered by: new commit pushed

@shahednasser shahednasser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution. Aside from the comments below, please remove all documenation changes from this PR. Instead, open a separate PR with the documentation change in www/apps/resources/app/js-sdk/page.mdx. This ensures the documentation change is only merged when the change in this PR is released. Other changes related to the OpenAPI specs are not necessary as thy're automatically generated on the next release.

Comment thread packages/core/js-sdk/src/client.ts
@medusa-os-bot

medusa-os-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

Thank you for the continued updates, @v0eak!

The latest commit removes the auto-generated spec files (www/apps/api-reference/specs/admin/ and www/utils/generated/oas-output/) from the PR — that non-blocking note from the prior review has been addressed. The rest of the code is unchanged from the last approved state.

req.queryreq.params fixed in both subscribe routes
✅ Route correctly renamed from [step_id]/subscribe to [transaction_id]/subscribe
FetchStreamResponse.stream changed from nullable to always non-null
✅ Tests use random IDs to prevent isolation conflicts
✅ Changeset included with correct minor bump

Notes (non-blocking):

  • Misleading variable name in packages/core/js-sdk/src/client.ts: const fetchPromise = await this.fetch_(...) — since the value is already await-ed, fetchPromise holds a resolved Response, not a Promise. The inner const res = await fetchPromise just re-awaits an already-resolved value. Renaming the outer binding to res or response would remove the confusion (noted in previous reviews as well).
  • No HTTP-level test for the subscribe route: The SSE test was removed because the test runner uses axios, which lacks SSE support — understood. Worth tracking as a follow-up once the test infrastructure supports streaming.

Triggered by: new commit pushed

@medusa-os-bot

medusa-os-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

Thank you for your continued updates, @v0eak!

After reviewing the latest commit, this PR remains approved. All previously required changes have been resolved.

req.queryreq.params fixed in both subscribe routes
✅ Route correctly renamed from [step_id]/subscribe to [transaction_id]/subscribe
FetchStreamResponse.stream changed from nullable to always non-null
✅ Tests use random IDs to prevent isolation conflicts
✅ Changeset included with correct minor bump
✅ Auto-generated spec files removed

One correction to a note carried across previous reviews: the fetchPromise variable in packages/core/js-sdk/src/client.ts is not misleading. In the current implementation, the fetch is assigned without await (const fetchPromise = this.fetch_(...)), so fetchPromise correctly holds a Promise<Response>. The prior note was based on an inaccurate reading of the diff — no change is needed here.

A team member will do a final review before this is merged. We appreciate your patience!


Notes (non-blocking):

  • No HTTP-level test for the subscribe route: The SSE-based API test was removed because the test runner uses axios, which lacks SSE support — understood. Worth tracking as a follow-up once the test infrastructure supports streaming.

Triggered by: new commit pushed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Workflow subscription API routes use req.query instead of req.params [Bug]: JS-SDK SSE stream cannot abort if no event streamed

2 participants