feat(builders): add after-bundle hook - #3751
lucamaraschi wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 761d0c8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
|
@lucamaraschi is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
25a8465 to
5ff146a
Compare
5ff146a to
15f0641
Compare
|
@vercel/workflow - ready for review |
pranaygp
left a comment
There was a problem hiding this comment.
Thanks, this is a clean, small addition and the tests go through the real createCombinedBundle → createManifest path, which I like. A few correctness problems in how "completed bundle" is tracked, plus some API and docs questions.
Correctness
1. A failed rebuild after an earlier success still fires the hook with stale artifacts. base-builder.ts:1844, :2435
The entry for a workflows path is only ever set, never cleared. The sequence "successful bundle → failed createCombinedBundle for the same flowOutfile → createManifest" still calls onAfterBundle. I reproduced it with the PR's own TestBuilder: the sequence gives 2 hook calls; with correct gating it would be 1. The "does not register artifacts when createCombinedBundle fails" test only covers failure on the first build. This contradicts "Failed or incomplete bundle and manifest builds do not invoke the hook."
It's also a problem when a failed rebuild has already overwritten the steps file before the workflows bundle threw: the hook is then told the pair is consistent when it isn't. Today's in-repo callers happen not to call createManifest after a failed bundle, but nothing enforces that, so it's a latent bug for any BaseBuilder subclass.
Suggested fix: this.completedBundleArtifacts.delete(resolve(workingDir, flowOutfile)) at the top of createCombinedBundle, before startWorkflowBuildTimer() at line 1709. Consider also consuming the entry in createManifest rather than leaving it in the map. Please add a test for "success → failed rebuild → manifest ⇒ no second call."
2. The reported workflows path can differ from the file that was actually written. base-builder.ts:415-431 vs :2364
recordCompletedBundleArtifacts resolves stepsOutfile and flowOutfile against config.workingDir. When bundleFinalOutput: false, though, the workflows file is written through writeGeneratedFile(flowOutfile) (plain fs, so relative to process.cwd()). The manifest path is resolved against cwd too (resolve(manifestDir, …)).
Reproduced: with a relative f.js/m and cwd !== workingDir, the hook reported <workingDir>/f.js (existsSync false) and <cwd>/m/manifest.json (true). That breaks the documented promise that paths are absolute and point at the artifacts.
The in-repo callers all pass absolute paths today, so this is latent. But the hook is new public API and the README promises absolute, real paths. Please either resolve all three paths the same way the writers do, or require/assert absolute paths (isAbsolute) and say so in the docs. Add a test with workingDir !== cwd and relative steps/workflows paths.
3. workflowManifest is not the manifest that was written. base-builder.ts:2442, types.ts:34
The hook receives the input manifest. What gets written to manifest.json is output, which adds version and converted steps, and adds a graph on every workflow. Repro output:
- Hook:
{"steps":…,"workflows":{…{"run":{"workflowId":…}}},"classes":{}} - File:
{"version":"1.0.0",…"run":{"workflowId":…,"graph":{"nodes":[],"edges":[]}}…}
Consumers wanting "the generated workflow manifest", as the PR description says, will expect the file's contents. Pick one and document it: pass the serialized output (or its typed form) in addition to or instead of the input, or name the field something like sourceManifest. As it stands, the internal SWC WorkflowManifest shape becomes part of the public hook contract, and its fields are mutable (only the top-level properties are readonly).
Risks / design
-
"Completed bundle" fires before the build is complete. In
VercelBuildOutputAPIBuilderit runs before the static manifest copy andcreateClientLibrary(). In Next it runs before the public-manifest copy andwriteFunctionsConfig. Anyone who, for example, uploads or publishes from the hook can observe a partially built output directory. Either document that the hook runs mid-build, or move the call to the end ofbuild()and the rebuild paths. -
A failed hook leaves the new manifest on disk. By the time the hook throws,
manifest.json(and the diagnostics copy) are already written and the timer is reset (:2387,:2421), so the caller sees a rejected build whose outputs are updated. Fine if intended; please document it. In the Next watch path the error is swallowed byenqueue'sconsole.error('Failed to process file change', …), so "rejects the rebuild" really means "logged". -
The hook fires for only some builders, and no user can reach it yet.
SimBuilder(world-sim/src/build.ts) callscreateCombinedBundlebut nevercreateManifest, so it never fires.- None of the framework integrations (Next, SvelteKit, Astro, Nest, Nitro, CLI) pass
onAfterBundlethrough from user config. Today only directBaseBuilderusers can set it.
The README says "Builder configurations can also provide an
onAfterBundleobserver" with no caveat. Please list which build targets invoke it, or note that it's builder-API only for now. -
Observability. There's no log or timing around the hook. A slow hook blocks every watch rebuild with no signal, and a hook error comes through raw, indistinguishable from a builder failure. Suggestions:
- wrap errors as
new Error('onAfterBundle hook failed', { cause }); - log the hook duration through the existing
logCreateManifestInfo/debug channel, so slow-HMR reports are diagnosable; - if the builders package has tracing spans around build phases, add one for the hook.
- wrap errors as
-
Performance. The hook is awaited serially on the hot-rebuild path (
hotRebuild→writeManifest). Worth a sentence in the docs that it should stay fast or hand heavy work off asynchronously. -
Security. The hook receives absolute filesystem paths and runs with builder privileges; expected for build-time config. No new trust boundary, so no concerns beyond documenting that it runs in the build process.
Missing tests
- Success → failed rebuild → manifest ⇒ not called (1).
workingDir !== cwdwith relative bundle paths ⇒ every reported path exists (2).- A test pinning what
workflowManifestcontains relative tomanifest.json(3). - Hook ordering relative to the webhook and public-manifest outputs, for at least one real builder (
StandaloneBuilderorVercelBuildOutputAPIBuilder), not just theTestBuilderdouble. - The watch test calls
createManifestdirectly. One test driving an actual rebuild loop, or the NextwriteManifestpath, would back up the "consistent invocation after watch rebuilds" claim. - A non-
Errorthrow (e.g. a string) propagates unchanged; this works today, it's just not pinned by a test.
Docs
- Only the package README is updated. If
onAfterTransformis documented underdocs/anywhere,onAfterBundlebelongs next to it; otherwise note that it's builder-API only. - The README should state: which targets invoke the hook; that it runs before the rest of the builder's outputs exist; which manifest shape it receives; and that
manifest.jsonstays updated when the hook throws.
Nits
base-builder.ts:225: the tuple type for the map could reuseWorkflowBundleArtifacts(e.g.Omit/slice) instead of restating it.- The changeset says "observer", while the PR title says "hook". Either works; just be consistent.
WorkflowBundleResult.workingDiris the raw config value. Resolve it, or document that it may be relative.- Consider freezing the artifacts array passed to the hook (
Object.freeze), since the type promisesreadonly.
Recommendation: request changes. Findings 1 (stale gating) and 2 (path resolution) break the documented contract, and 3 needs a decision before this becomes public API. The rest are small.
I'm not sure what this means exactly. Could you describe your use case? |
15f0641 to
61e4899
Compare
|
Platformatic is adding support for remote workflow handlers. After Workflow SDK emits the bundles and manifest, we need to map the public handler names and schemas to the built workflow IDs. |
61e4899 to
cc4c4a9
Compare
|
Follow-up: expanded
The existing generic builder lifecycle is unchanged. These updates expose the optional hook through each framework’s public configuration surface and forward it to the underlying builder. Nitro also now reports an accurate Added adapter-level tests, documentation, and a changeset. When the hook is not configured, behavior and performance remain unchanged. Local validation passed for all affected package tests and builds. |
Signed-off-by: Luca Maraschi <luca.maraschi@gmail.com>
cc4c4a9 to
761d0c8
Compare
|
For context, we’ve prepared the follow-on Platformatic work that will consume this lifecycle capability:
These are separate, public PRs and are provided as references for the planned integration. The direct APIs remain usable independently; the automatic build-to-runtime path consumes the |
Adds an awaited
onAfterBundlehook to@workflow/builders.The hook runs after a successful workflow bundle and manifest build and provides:
Hook errors propagate to the caller. Failed or incomplete bundle and manifest builds do not invoke the hook.
This enables integrations to consume completed workflow build artifacts without reproducing the builder lifecycle internally.
Nitro-specific integration is intentionally excluded and will be handled separately.
How did you test your changes?
@workflow/buildersPR Checklist - Required to merge
@workflow/buildersSigned-off-bytrailer@vercel/workflowonce the PR is ready for review