From b89b982d438512b80f7f325e6b76d1f353162635 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:01:03 +0000 Subject: [PATCH] [core] Make `hook.metadata` a lazy Promise getter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hydrating a hook's metadata is a decrypting READ: it needs the owning run's payload keys, and resolving those costs a run fetch plus a `run-key` API round trip (~350ms). `getHookByToken()` did that work eagerly on every lookup that found a metadata-bearing hook, so callers that only wanted `runId`/`token` — and hook resumption, which never reads metadata at all — paid for it anyway. `metadata` is now a getter returning a memoized Promise, the same shape as `run.returnValue`. The lookup is one read again; hydration and the key resolution behind it happen on first access, or never. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> --- .changeset/lazy-hook-metadata-getter.md | 7 + .../workflow-api/get-hook-by-token.mdx | 15 +- packages/core/e2e/e2e-region.test.ts | 6 +- packages/core/e2e/e2e.test.ts | 20 +- packages/core/src/create-hook.ts | 3 + packages/core/src/runtime.ts | 1 + .../src/runtime/get-hook-by-token.test.ts | 278 ++++++++++++++++++ .../src/runtime/resume-hook.fast-path.test.ts | 2 +- packages/core/src/runtime/resume-hook.ts | 249 ++++++++++++---- packages/workflow/src/api.ts | 1 + packages/world-testing/src/server.mts | 3 +- 11 files changed, 510 insertions(+), 75 deletions(-) create mode 100644 .changeset/lazy-hook-metadata-getter.md create mode 100644 packages/core/src/runtime/get-hook-by-token.test.ts diff --git a/.changeset/lazy-hook-metadata-getter.md b/.changeset/lazy-hook-metadata-getter.md new file mode 100644 index 0000000000..93dddb71fe --- /dev/null +++ b/.changeset/lazy-hook-metadata-getter.md @@ -0,0 +1,7 @@ +--- +'@workflow/core': major +'workflow': major +'@workflow/world-testing': patch +--- + +**Breaking:** `hook.metadata` is now a lazy getter that returns a Promise, like `run.returnValue` — `await hook.metadata` to read it, and the accessor is non-enumerable, so it is absent from `{ ...hook }` and `JSON.stringify(hook)`. That makes `getHookByToken()` a single read (the run fetch and `run-key` round trip hydration needs are only paid by callers that access metadata, so hook resumption stops paying them), and `resumeHook()`'s returned hook now hydrates metadata instead of exposing raw serialized bytes. diff --git a/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx b/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx index edb05c9f4a..41fc07b104 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx @@ -17,6 +17,10 @@ When `experimental_minRetention` is set, this function continues to return the H `getHookByToken` is a runtime function that must be called from outside a workflow function. + +`hook.metadata` is a getter that returns a Promise, so `await` it to read the value. Hydrating metadata decrypts it with the owning run's keys, which can cost a run fetch and a key round trip, so the work is deferred to first access and the lookup itself stays a single read. Awaiting it on a hook with no metadata resolves `undefined` and performs no extra work, and repeat reads are free. Like `run.returnValue`, the getter is non-enumerable: it does not appear in `{ ...hook }` or `JSON.stringify(hook)`, so forward the awaited value explicitly if you need to serialize it. + + Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). @@ -44,12 +48,12 @@ showSections={["parameters"]} ### Returns -Returns a `Promise` that resolves to: +Returns a `Promise` that resolves to: @@ -70,7 +74,7 @@ export async function POST(request: Request) { const hook = await getHookByToken(token); // [!code highlight] console.log("Resuming workflow run:", hook.runId); - console.log("Hook metadata:", hook.metadata); + console.log("Hook metadata:", await hook.metadata); // [!code highlight] // Then resume the hook with the payload await resumeHook(token, data); @@ -97,7 +101,8 @@ export async function POST(request: Request) { try { const hook = await getHookByToken(token); // [!code highlight] - const metadata = hook.metadata as { allowedUserId?: string } | undefined; + // `metadata` is a Promise, so awaiting it hydrates the stored value. + const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined; // [!code highlight] // Validate that the hook metadata matches the user if (metadata?.allowedUserId !== userId) { diff --git a/packages/core/e2e/e2e-region.test.ts b/packages/core/e2e/e2e-region.test.ts index 002fb74b62..7c3929083c 100644 --- a/packages/core/e2e/e2e-region.test.ts +++ b/packages/core/e2e/e2e-region.test.ts @@ -446,9 +446,9 @@ describe.skipIf(isLocalDeployment())('multi-region (world-vercel)', () => { // Resolve by opaque token from the test process. const hook = await waitForHook(token, run.runId); expect(hook.runId).toBe(run.runId); - expect((hook.metadata as { customData?: string })?.customData).toBe( - label - ); + expect( + ((await hook.metadata) as { customData?: string })?.customData + ).toBe(label); // Resume the suspended run by token — twice, sequentially, so // the payload order in the run's event log is deterministic. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index ab2abe2b10..3bb38e9521 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -676,7 +676,7 @@ describe.concurrent('e2e', () => { expect(hook.runId).toBe(run.runId); await resumeHook(hook, { message: 'one', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Invalid token test @@ -687,7 +687,7 @@ describe.concurrent('e2e', () => { expect(hook.runId).toBe(run.runId); await resumeHook(hook, { message: 'two', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Resume with third (final) payload @@ -696,7 +696,7 @@ describe.concurrent('e2e', () => { await resumeHook(hook, { message: 'three', done: true, - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); const returnValue = await run.returnValue; @@ -737,7 +737,7 @@ describe.concurrent('e2e', () => { // Now resume via server-side resumeHook() — should work await resumeHook(hook, { message: 'via-server', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, done: true, }); @@ -2056,7 +2056,7 @@ describe.concurrent('e2e', () => { expect(hook.runId).toBe(run1.runId); await resumeHook(hook, { message: 'test-message-1', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Get first workflow result @@ -2080,7 +2080,7 @@ describe.concurrent('e2e', () => { expect(hook.runId).toBe(run2.runId); await resumeHook(hook, { message: 'test-message-2', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Get second workflow result @@ -2142,7 +2142,7 @@ describe.concurrent('e2e', () => { const hook = await getHookByToken(token); await resumeHook(hook, { message: 'test-concurrent', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Verify workflow 1 completed successfully @@ -2299,7 +2299,7 @@ describe.concurrent('e2e', () => { await resumeHook(hook, { message: 'ready-conflict-holder', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); const run1Result = await run1.returnValue; @@ -2637,7 +2637,7 @@ describe.concurrent('e2e', () => { // Send payload to first workflow - this will trigger it to dispose the hook await resumeHook(hook, { message: 'first-payload', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Wait for workflow 1 to release the token before starting workflow 2. @@ -2659,7 +2659,7 @@ describe.concurrent('e2e', () => { // Send payload to workflow 2 await resumeHook(hook, { message: 'second-payload', - customData: (hook.metadata as any)?.customData, + customData: ((await hook.metadata) as any)?.customData, }); // Wait for both workflows to complete diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index 358101c704..cdf57cfffa 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -174,6 +174,9 @@ export interface HookOptions { /** * Additional user-defined data to include with the hook payload. * + * Read it back outside the workflow with `getHookByToken()`, where + * `hook.metadata` is a Promise: `await hook.metadata`. + * * @example * * ```ts diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index d9699fad35..dc1c50f3aa 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -152,6 +152,7 @@ export { } from './runtime/helpers.js'; export { getHookByToken, + type HookWithLazyMetadata, type ResumedHook, resumeHook, resumeWebhook, diff --git a/packages/core/src/runtime/get-hook-by-token.test.ts b/packages/core/src/runtime/get-hook-by-token.test.ts new file mode 100644 index 0000000000..a4682206e5 --- /dev/null +++ b/packages/core/src/runtime/get-hook-by-token.test.ts @@ -0,0 +1,278 @@ +import { + type Hook, + SPEC_VERSION_CURRENT, + type WorkflowRun, + type World, +} from '@workflow/world'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { hydrateStepArguments } from '../serialization.js'; +import { getHookByToken, resumeHook } from './resume-hook.js'; +import { setWorld } from './world.js'; + +vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() })); +vi.mock('../telemetry.js', () => ({ + linkToTraceCarrier: vi.fn(), + trace: vi.fn((_name, fn) => fn(undefined)), +})); +// Stub (de)serialization so these tests assert *when* hydration happens rather +// than devalue/encryption byte behavior, which the sibling `resume-hook.test.ts` +// covers with real serialization. +vi.mock('../serialization.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dehydrateStepReturnValue: vi.fn(async () => 'serialized'), + hydrateStepArguments: vi.fn(async (value: unknown) => value), + }; +}); + +const hydrateSpy = vi.mocked(hydrateStepArguments); + +describe('getHookByToken (lazy metadata)', () => { + afterEach(() => { + setWorld(undefined); + vi.clearAllMocks(); + }); + + const baseHook = { + runId: 'wrun_lazy', + hookId: 'hook_lazy', + token: 'order:lazy', + ownerId: 'owner_1', + projectId: 'project_1', + environment: 'production', + createdAt: new Date(), + specVersion: SPEC_VERSION_CURRENT, + } satisfies Hook; + + const resumeContext = { + deploymentId: 'deployment_lazy', + workflowName: 'processOrder', + runSpecVersion: SPEC_VERSION_CURRENT, + workflowCoreVersion: '5.0.0', + }; + + // A syntactically valid 32-byte X25519 public key (base64), enough for + // `decodeRunPublicKey` to accept it. With one in the resume context, a + // resume seals its payload and resolves no key of its own — so any + // `getEncryptionKeyForRun` call can only have come from metadata hydration. + const resumeContextWithKey = { + ...resumeContext, + encryptionPublicKey: 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=', + }; + + const makeWorld = ( + hook: Hook, + overrides: Partial>> = {} + ) => { + const getByToken = overrides.getByToken ?? vi.fn().mockResolvedValue(hook); + const runsGet = overrides.runsGet ?? vi.fn(); + const getEncryptionKeyForRun = + overrides.getEncryptionKeyForRun ?? + vi.fn().mockResolvedValue(new Uint8Array(32).fill(0x4d)); + setWorld({ + specVersion: SPEC_VERSION_CURRENT, + hooks: { getByToken }, + runs: { get: runsGet }, + events: { create: overrides.createEvent ?? vi.fn() }, + getEncryptionKeyForRun, + queue: overrides.queue ?? vi.fn(), + } as unknown as World); + return { getByToken, runsGet, getEncryptionKeyForRun }; + }; + + it('costs a single read even when the hook carries metadata', async () => { + // The whole point: looking a hook up by token must not pay the run fetch + // and `run-key` API round trip that hydrating metadata needs. Callers that + // only want `runId`/`token` — hook resumption above all — never touch it. + const hook = { + ...baseHook, + resumeContext, + metadata: { customData: 'stored' } as unknown as Hook['metadata'], + } satisfies Hook; + const { getByToken, runsGet, getEncryptionKeyForRun } = makeWorld(hook); + + const found = await getHookByToken(hook.token); + + expect(found.runId).toBe(hook.runId); + expect(getByToken).toHaveBeenCalledTimes(1); + expect(runsGet).not.toHaveBeenCalled(); + expect(getEncryptionKeyForRun).not.toHaveBeenCalled(); + expect(hydrateSpy).not.toHaveBeenCalled(); + }); + + it('hydrates metadata on first access and memoizes it', async () => { + const metadata = { customData: 'stored' }; + const hook = { + ...baseHook, + resumeContext, + metadata: metadata as unknown as Hook['metadata'], + } satisfies Hook; + const { runsGet, getEncryptionKeyForRun } = makeWorld(hook); + + const found = await getHookByToken(hook.token); + + expect(await found.metadata).toEqual(metadata); + // Metadata is fixed at hook-creation time, so repeat reads are free. + expect(await found.metadata).toEqual(metadata); + expect(hydrateSpy).toHaveBeenCalledTimes(1); + expect(getEncryptionKeyForRun).toHaveBeenCalledTimes(1); + // Hydration resolves the key from the stored resume context — still no + // run read, just as the resume path does. + expect(getEncryptionKeyForRun).toHaveBeenCalledWith(hook.runId, { + deploymentId: resumeContext.deploymentId, + }); + expect(runsGet).not.toHaveBeenCalled(); + // Hydrating metadata is a decrypting READ, so it gets the derived + // read-side payload keys rather than no key at all. + expect(hydrateSpy.mock.calls[0]?.[2]).toBeDefined(); + }); + + it('resolves undefined with no I/O when the hook stored no metadata', async () => { + // The common default webhook — createWebhook() with no `respondWith`. + // Awaiting is safe and free, so callers never need to branch first. + const hook = { ...baseHook, resumeContext } satisfies Hook; + const { runsGet, getEncryptionKeyForRun } = makeWorld(hook); + + const found = await getHookByToken(hook.token); + + expect(await found.metadata).toBeUndefined(); + expect(runsGet).not.toHaveBeenCalled(); + expect(getEncryptionKeyForRun).not.toHaveBeenCalled(); + expect(hydrateSpy).not.toHaveBeenCalled(); + }); + + it('falls back to a run read when the hook has no resumeContext', async () => { + const hook = { + ...baseHook, + metadata: { customData: 'stored' } as unknown as Hook['metadata'], + } satisfies Hook; + const run = { + runId: hook.runId, + status: 'running', + deploymentId: 'deployment_fallback', + workflowName: 'processOrder', + specVersion: SPEC_VERSION_CURRENT, + createdAt: new Date(), + updatedAt: new Date(), + attributes: {}, + } as unknown as WorkflowRun; + const runsGet = vi.fn().mockResolvedValue(run); + const { getEncryptionKeyForRun } = makeWorld(hook, { runsGet }); + + const found = await getHookByToken(hook.token); + expect(runsGet).not.toHaveBeenCalled(); + + await found.metadata; + + expect(runsGet).toHaveBeenCalledWith(hook.runId); + // The fallback reuses the run it just fetched rather than re-resolving by + // runId + deploymentId. + expect(getEncryptionKeyForRun).toHaveBeenCalledWith(run); + }); + + it('surfaces a hydration failure on access, not on lookup', async () => { + // A lookup that only reads `runId` must not fail because the run key is + // unreachable; the error belongs to whoever awaits `metadata`. + const hook = { + ...baseHook, + resumeContext, + metadata: { customData: 'stored' } as unknown as Hook['metadata'], + } satisfies Hook; + makeWorld(hook, { + getEncryptionKeyForRun: vi + .fn() + .mockRejectedValue(new Error('run-key unavailable')), + }); + + const found = await getHookByToken(hook.token); + + expect(found.runId).toBe(hook.runId); + await expect(found.metadata).rejects.toThrow('run-key unavailable'); + }); + + it('propagates a failed lookup', async () => { + makeWorld(baseHook, { + getByToken: vi.fn().mockRejectedValue(new Error('hook not found')), + }); + + await expect(getHookByToken('nope')).rejects.toThrow('hook not found'); + }); + + it('leaves the World-supplied record untouched', async () => { + // The wrap is a shallow copy: a World that caches or reuses hook records + // must not end up with a Promise where its serialized bytes were. + const metadata = { customData: 'stored' } as unknown as Hook['metadata']; + const hook = { ...baseHook, resumeContext, metadata } satisfies Hook; + makeWorld(hook); + + const found = await getHookByToken(hook.token); + await found.metadata; + + expect(hook.metadata).toBe(metadata); + }); + + it('does not hydrate when the hook is spread or serialized', async () => { + // The accessor is non-enumerable, like `Run.returnValue`. An incidental + // spread or `JSON.stringify` must not kick off hydration nobody awaits — + // an unconsumed rejected Promise takes the process down. + const hook = { + ...baseHook, + resumeContext, + metadata: { customData: 'stored' } as unknown as Hook['metadata'], + } satisfies Hook; + const { getEncryptionKeyForRun } = makeWorld(hook); + + const found = await getHookByToken(hook.token); + const spread = { ...found }; + JSON.stringify(found); + + expect(getEncryptionKeyForRun).not.toHaveBeenCalled(); + expect(hydrateSpy).not.toHaveBeenCalled(); + expect(spread).not.toHaveProperty('metadata'); + // Everything else still comes along. + expect(spread.runId).toBe(hook.runId); + expect(spread.token).toBe(hook.token); + }); + + it('gives a resumed hook the same lazy metadata accessor', async () => { + // `resumeHook` used to hand back the raw record, whose `metadata` was still + // the serialized bytes. It now carries the same lazy accessor — and because + // it is lazy, resuming still pays nothing for metadata it never reads. + const metadata = { customData: 'stored' }; + const hook = { + ...baseHook, + resumeContext: resumeContextWithKey, + metadata: metadata as unknown as Hook['metadata'], + } satisfies Hook; + const { runsGet, getEncryptionKeyForRun } = makeWorld(hook); + + const resumed = await resumeHook(hook.token, { approved: true }); + + expect(getEncryptionKeyForRun).not.toHaveBeenCalled(); + expect(hydrateSpy).not.toHaveBeenCalled(); + + expect(await resumed.metadata).toEqual(metadata); + expect(runsGet).not.toHaveBeenCalled(); + expect(hydrateSpy).toHaveBeenCalledTimes(1); + }); + + it('does not double-wrap a hook handed back to resumeHook', async () => { + // Passing the result of `getHookByToken` into `resumeHook` must not wrap a + // Promise in another Promise (which would hand hydration a thenable). + const metadata = { customData: 'stored' }; + const hook = { + ...baseHook, + resumeContext, + metadata: metadata as unknown as Hook['metadata'], + } satisfies Hook; + makeWorld(hook); + + const found = await getHookByToken(hook.token); + const resumed = await resumeHook(found, { approved: true }); + + expect(await resumed.metadata).toEqual(metadata); + expect(hydrateSpy).toHaveBeenCalledTimes(1); + expect(hydrateSpy.mock.calls[0]?.[0]).toBe(metadata); + }); +}); diff --git a/packages/core/src/runtime/resume-hook.fast-path.test.ts b/packages/core/src/runtime/resume-hook.fast-path.test.ts index 2b01a0daaa..eda9ea6b40 100644 --- a/packages/core/src/runtime/resume-hook.fast-path.test.ts +++ b/packages/core/src/runtime/resume-hook.fast-path.test.ts @@ -215,7 +215,7 @@ describe('resumeHook (resumeContext fast path)', () => { it('resumeWebhook default (no metadata) pays no key lookup and seals to the run', async () => { // The common default webhook — createWebhook() with no `respondWith` — - // stores no metadata, so getHookByTokenWithKey resolves no key and + // stores no metadata, so awaiting `hook.metadata` resolves no key and // resumeHook seals to the run's public key carried in the resume context. // Zero run reads, zero `run-key` API round trips. (Byte-level `encp` is // asserted with real serialization in resume-hook.test.ts.) diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index daab2013a3..a183425d39 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -126,6 +126,66 @@ async function publishHookWakeWithRetry( throw lastError; } +/** + * A hook record with its serialized `metadata` omitted: everything a resume + * actually reads. Resuming never looks at metadata, so the resume path accepts + * both a raw {@link Hook} straight out of a World and a + * {@link HookWithLazyMetadata} whose `metadata` is a Promise. + */ +type ResumableHook = Omit; + +/** + * A {@link Hook} whose user-defined `metadata` is hydrated lazily. + * + * `metadata` is a getter that returns a Promise — the same shape as + * `Run.returnValue` — so looking a hook up by token costs exactly one read. + * Hydrating metadata is a decrypting READ that needs the owning run's payload + * keys, and resolving those can cost a run fetch plus a `run-key` API round + * trip (~350ms). Deferring that to first access keeps the lookup fast for the + * many callers that only need `runId`/`token` — most importantly hook + * resumption, which never reads metadata at all. + * + * The promise is memoized: hydration (and the key resolution behind it) runs at + * most once per hook object. Awaiting it on a hook that stored no metadata + * resolves `undefined` and performs no I/O. + * + * Like `Run.returnValue`, the accessor is non-enumerable, so it is absent from + * `{ ...hook }` and `JSON.stringify(hook)` — read it explicitly and include the + * awaited value if you need to forward it. + * + * @example + * + * ```ts + * const hook = await getHookByToken(token); + * console.log(hook.runId); // no metadata work + * const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined; + * ``` + */ +export interface HookWithLazyMetadata extends ResumableHook { + /** + * The hook's user-defined metadata, hydrated on first access and memoized. + * Resolves `undefined` when the hook carries no metadata. + */ + readonly metadata: Promise; +} + +/** + * A by-token hook lookup: the hook itself plus access to the payload keys that + * hydrating its `metadata` resolved, if anything ever awaited it. + */ +interface HookLookup { + hook: HookWithLazyMetadata; + /** + * The read-side payload keys resolved while hydrating `metadata`, or + * `undefined` when `metadata` was never awaited, the hook stored none, or the + * run has no key (encryption disabled). Lets `resumeWebhook` — the one caller + * that must read metadata — reuse that key for the payload WRITE instead of + * paying a second `run-key` round trip. Only meaningful after awaiting + * `hook.metadata`. + */ + metadataEncryptionKey(): PayloadKey | undefined; +} + /** * The resume context for a hook plus where it came from. `run` is present only * on the fallback path (pre-`resumeContext` hooks), where it also carries the @@ -170,7 +230,9 @@ function resumeContextFromRun(run: WorkflowRun): HookResumeContext { * seal/serialization work may run before the receiving side rejects * `hook_received` for an ended run. */ -async function resolveHookResumeInfo(hook: Hook): Promise { +async function resolveHookResumeInfo( + hook: ResumableHook +): Promise { if (hook.resumeContext) { return { resumeContext: hook.resumeContext, source: 'hook' }; } @@ -190,7 +252,7 @@ async function resolveHookResumeInfo(hook: Hook): Promise { * entity); on the fallback path the already fetched run is reused. */ async function resolveHookEncryptionKey( - hook: Hook, + hook: ResumableHook, info: HookResumeInfo ): Promise> | undefined> { const world = await getWorldLazy(); @@ -202,66 +264,126 @@ async function resolveHookEncryptionKey( return rawKey ? await importKey(rawKey) : undefined; } -async function getHookByTokenWithKey(token: string): Promise<{ - hook: Hook; - encryptionKey: PayloadKey | undefined; -}> { - const world = await getWorldLazy(); - const hook = await world.hooks.getByToken(token); - - // Only a hook that actually carries metadata needs the run's key resolved - // here: hydrating that metadata is a READ, so derive the full RunPayloadKeys - // (which opens sealed `encp` metadata, not just symmetric `encr`). The common - // default webhook (createWebhook() with no `respondWith`) stores no - // metadata, so it skips this entirely: no ~350ms `run-key` API round trip, - // and, crucially, no resolved key handed to `resumeHook`, leaving it free to - // seal the payload to the run's published public key instead. Metadata- - // bearing webhooks still legitimately pay one lookup to hydrate. +/** Whether `metadata` on this record is already the lazy Promise accessor. */ +function hasLazyMetadata(hook: ResumableHook): boolean { + const descriptor = Object.getOwnPropertyDescriptor(hook, 'metadata'); + return descriptor !== undefined && typeof descriptor.get === 'function'; +} + +/** + * Wraps a raw hook record from a World in a {@link HookWithLazyMetadata}, + * replacing its serialized `metadata` with a memoized Promise getter that + * hydrates on first access. + * + * Nothing here touches the network: the wrap is a property definition. All of + * the cost — resolving the run's payload keys and decrypting — moves inside the + * getter, so a lookup that never reads metadata pays for exactly one + * `hooks.getByToken`. + * + * The original record is left untouched; the returned object is a shallow copy + * carrying the accessor. + */ +function withLazyMetadata(raw: Hook): HookLookup { + const serialized = raw.metadata; + let hydrated: Promise | undefined; let encryptionKey: PayloadKey | undefined; - if (typeof hook.metadata !== 'undefined') { - const info = await resolveHookResumeInfo(hook); + + // Hydrating metadata is a decrypting READ, so it derives the full + // RunPayloadKeys (which open sealed `encp` metadata, not just symmetric + // `encr`) rather than the bare write key the resume path uses. + const hydrate = async (): Promise => { + const world = await getWorldLazy(); + const info = await resolveHookResumeInfo(raw); // On the fast path this resolves the key by runId + deploymentId (no run // read); on the fallback path it reuses the already-fetched run. const rawKey = info.run ? await world.getEncryptionKeyForRun?.(info.run) - : await world.getEncryptionKeyForRun?.(hook.runId, { + : await world.getEncryptionKeyForRun?.(raw.runId, { deploymentId: info.resumeContext.deploymentId, }); encryptionKey = rawKey ? await deriveRunPayloadKeys(rawKey) : undefined; - hook.metadata = await hydrateStepArguments( - hook.metadata as any, - hook.runId, + return await hydrateStepArguments( + serialized as any, + raw.runId, encryptionKey ); - } - return { hook, encryptionKey }; + }; + + const hook = Object.create( + Object.getPrototypeOf(raw), + Object.getOwnPropertyDescriptors(raw) + ) as HookWithLazyMetadata; + Object.defineProperty(hook, 'metadata', { + // A hook with no metadata resolves `undefined` without any I/O, so callers + // can await unconditionally. Memoized either way: metadata is fixed at + // hook-creation time, so hydration runs at most once per hook object. + get: () => + (hydrated ??= + typeof serialized === 'undefined' + ? Promise.resolve(undefined) + : hydrate()), + // Non-enumerable, matching `Run.returnValue` (a prototype getter, so it is + // absent from an instance's own keys). Spreading or `JSON.stringify`-ing a + // hook therefore cannot trigger hydration nobody asked for — which would + // otherwise leave a floating rejection when the run key is unreachable, and + // an unconsumed rejected Promise crashes Node as an unhandledRejection. + enumerable: false, + configurable: true, + }); + + return { hook, metadataEncryptionKey: () => encryptionKey }; +} + +/** + * Normalizes any hook record the resume path accepted into a + * {@link HookWithLazyMetadata} to return to the caller. Idempotent: a hook that + * already carries the lazy accessor (one that came from `getHookByToken`) is + * returned as-is rather than double-wrapped, which would hand + * `hydrateStepArguments` a Promise. + */ +function asLazyMetadataHook(hook: ResumableHook): HookWithLazyMetadata { + return hasLazyMetadata(hook) + ? (hook as HookWithLazyMetadata) + : withLazyMetadata(hook as Hook).hook; } /** - * Get the hook by token to find the associated workflow run, - * and hydrate the `metadata` property if it was set from within - * the workflow run. + * Get the hook by token to find the associated workflow run. + * + * This is a single read. The returned hook's `metadata` is a getter that + * resolves a Promise (see {@link HookWithLazyMetadata}), so the run fetch and + * `run-key` round trip that hydrating it can require are only paid by callers + * that actually await it: + * + * ```ts + * const hook = await getHookByToken(token); + * const metadata = await hook.metadata; + * ``` * * A Hook kept by minimum retention remains available here after its run ends, * but cannot be resumed. * * @param token - The unique token identifying the hook */ -export async function getHookByToken(token: string): Promise { - const { hook } = await getHookByTokenWithKey(token); - return hook; +export async function getHookByToken( + token: string +): Promise { + const world = await getWorldLazy(); + return withLazyMetadata(await world.hooks.getByToken(token)).hook; } /** - * The result of {@link resumeHook}: a {@link Hook} augmented with an optional - * resilience signal. + * The result of {@link resumeHook}: a {@link HookWithLazyMetadata} augmented + * with an optional resilience signal. * * `resilientResume` is retained for source compatibility and is never set. * `resumeHook()` now requires the durable `hook_received` write and workflow * wake to both succeed before it resolves. Treat the result as a plain - * {@link Hook}. + * {@link HookWithLazyMetadata}. */ -export type ResumedHook = Hook & { resilientResume?: boolean }; +export type ResumedHook = HookWithLazyMetadata & { + resilientResume?: boolean; +}; /** * Resumes a workflow run by sending a payload to a hook identified by its token. @@ -309,7 +431,7 @@ export type ResumedHook = Hook & { resilientResume?: boolean }; * ``` */ export async function resumeHook( - tokenOrHook: string | Hook, + tokenOrHook: string | ResumableHook, payload: T, encryptionKeyOverride?: PayloadKey ): Promise { @@ -352,7 +474,7 @@ export async function resumeHook( * would report the same metric over different windows. */ async function resumeHookImpl( - tokenOrHook: string | Hook, + tokenOrHook: string | ResumableHook, payload: T, encryptionKeyOverride: PayloadKey | undefined, hookFreshlyLookedUp: boolean, @@ -364,7 +486,7 @@ async function resumeHookImpl( try { const suppliedToken = typeof tokenOrHook === 'string'; - const hook: Hook = suppliedToken + const hook: ResumableHook = suppliedToken ? await world.hooks.getByToken(tokenOrHook) : tokenOrHook; // The dynamic, response-only `resumeCapabilities` may only be trusted @@ -644,7 +766,7 @@ async function resumeHookImpl( ); span?.setAttributes(Attribute.HookWakePublished(true)); - return hook satisfies ResumedHook; + return asLazyMetadataHook(hook) satisfies ResumedHook; } catch (err) { span?.setAttributes({ ...Attribute.HookToken( @@ -703,7 +825,10 @@ export async function resumeWebhook( // and not inside `resumeHookImpl`; otherwise webhook resumes would report a // systematically shorter total than `resumeHook` ones into the same metric. const resumeRequestedAtMs = Date.now(); - const { hook, encryptionKey } = await getHookByTokenWithKey(token); + const world = await getWorldLazy(); + const { hook, metadataEncryptionKey } = withLazyMetadata( + await world.hooks.getByToken(token) + ); // Only webhooks can be resumed via the public endpoint. // If the hook was created via createHook() (isWebhook !== true), @@ -713,25 +838,29 @@ export async function resumeWebhook( throw new HookNotFoundError(token); } + // `respondWith` lives in the hook's metadata, so this is the one resume path + // that has to read it. Only a webhook that actually stored metadata pays for + // the hydration: the common default webhook — createWebhook() with no + // `respondWith` — stores none, so this resolves `undefined` with no ~350ms + // `run-key` API round trip, and hands `resumeHook` no key, leaving it free to + // seal the payload to the run's published public key instead. + const metadata = await hook.metadata; + let response: Response | undefined; let responseReadable: ReadableStream | undefined; - if ( - hook.metadata && - typeof hook.metadata === 'object' && - 'respondWith' in hook.metadata - ) { - if (hook.metadata.respondWith === 'manual') { + if (metadata && typeof metadata === 'object' && 'respondWith' in metadata) { + if (metadata.respondWith === 'manual') { const { readable, writable } = new TransformStream(); responseReadable = readable; // The request instance includes the writable stream which will be used // to write the response to the client from within the workflow run (request as any)[WEBHOOK_RESPONSE_WRITABLE] = writable; - } else if (hook.metadata.respondWith instanceof Response) { - response = hook.metadata.respondWith; + } else if (metadata.respondWith instanceof Response) { + response = metadata.respondWith; } else { throw new WorkflowRuntimeError( - `Invalid \`respondWith\` value: ${hook.metadata.respondWith}`, + `Invalid \`respondWith\` value: ${metadata.respondWith}`, { slug: ERROR_SLUGS.WEBHOOK_INVALID_RESPOND_WITH_VALUE } ); } @@ -740,12 +869,22 @@ export async function resumeWebhook( response = new Response(null, { status: 202 }); } - // `hook` was just fetched via `getHookByTokenWithKey` (a fresh by-token - // lookup) above, so its response-only `resumeCapabilities` reflects the live - // backend. Call the internal implementation with the fresh attestation so - // the write's idempotency claim stays available without a second GET. (The - // public `resumeHook` never sets this, so a caller cannot forge it.) - await resumeHookImpl(hook, request, encryptionKey, true, resumeRequestedAtMs); + // `hook` was just fetched by token above, so its response-only + // `resumeCapabilities` reflects the live backend. Call the internal + // implementation with the fresh attestation so the write's idempotency claim + // stays available without a second GET. (The public `resumeHook` never sets + // this, so a caller cannot forge it.) + // + // Reuse whatever key the metadata hydration above resolved (`undefined` when + // it resolved none) so a metadata-bearing webhook resolves the run key + // exactly once end to end. + await resumeHookImpl( + hook, + request, + metadataEncryptionKey(), + true, + resumeRequestedAtMs + ); if (responseReadable) { // Wait for the readable stream to emit one chunk, diff --git a/packages/workflow/src/api.ts b/packages/workflow/src/api.ts index 31ca6ed146..cdc0d676e7 100644 --- a/packages/workflow/src/api.ts +++ b/packages/workflow/src/api.ts @@ -16,6 +16,7 @@ export type { } from '@workflow/core/runtime'; export { getHookByToken, + type HookWithLazyMetadata, type ResumedHook, resumeHook, resumeWebhook, diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index 069d1e1588..1fa236331e 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -91,7 +91,8 @@ const app = new Hono() const hook = await getHookByToken(ctx.req.param('token')); const { runId } = await resumeHook(hook.token, { ...(await ctx.req.json()), - metadata: hook.metadata, + // `metadata` is a lazily-hydrated Promise; echo the resolved value. + metadata: await hook.metadata, }); return ctx.json({ runId, hookId: hook.hookId }); })