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 });
})