handoff-app 1.2.2-6 → 2.0.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (349) hide show
  1. package/Changelog.md +37 -15
  2. package/README.md +372 -118
  3. package/dist/app-builder/build.d.ts +41 -2
  4. package/dist/app-builder/build.js +455 -18
  5. package/dist/app-builder/client-config.d.ts +15 -2
  6. package/dist/app-builder/client-config.js +43 -4
  7. package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
  8. package/dist/app-builder/config-diff/snapshot.js +2 -9
  9. package/dist/app-builder/index.d.ts +1 -1
  10. package/dist/app-builder/index.js +3 -1
  11. package/dist/app-builder/nav-shell.d.ts +23 -0
  12. package/dist/app-builder/nav-shell.js +80 -0
  13. package/dist/app-builder/static-export.d.ts +17 -0
  14. package/dist/app-builder/static-export.js +181 -0
  15. package/dist/app-builder/vercel-output.d.ts +39 -0
  16. package/dist/app-builder/vercel-output.js +259 -0
  17. package/dist/app-builder/watchers/utils.d.ts +2 -0
  18. package/dist/app-builder/watchers.d.ts +14 -4
  19. package/dist/app-builder/watchers.js +123 -28
  20. package/dist/app-builder/websocket.js +1 -1
  21. package/dist/artifacts/index.d.ts +2 -0
  22. package/dist/artifacts/index.js +13 -0
  23. package/dist/artifacts/types.d.ts +136 -0
  24. package/dist/artifacts/types.js +24 -0
  25. package/dist/artifacts/url.d.ts +49 -0
  26. package/dist/artifacts/url.js +86 -0
  27. package/dist/cache/build-cache.js +48 -4
  28. package/dist/commands/build/app.js +1 -2
  29. package/dist/commands/build/index.d.ts +17 -0
  30. package/dist/commands/build/index.js +66 -0
  31. package/dist/commands/checkout/index.d.ts +24 -0
  32. package/dist/commands/checkout/index.js +71 -0
  33. package/dist/commands/db/migrate.d.ts +11 -0
  34. package/dist/commands/db/migrate.js +33 -0
  35. package/dist/commands/index.js +8 -0
  36. package/dist/commands/publish/index.d.ts +21 -0
  37. package/dist/commands/publish/index.js +68 -0
  38. package/dist/config/defaults.js +15 -1
  39. package/dist/config/loader.js +6 -1
  40. package/dist/config/normalizers/declaration.js +4 -1
  41. package/dist/config/normalizers/page.d.ts +18 -0
  42. package/dist/config/normalizers/page.js +34 -0
  43. package/dist/config/normalizers/pattern.js +7 -1
  44. package/dist/config/runtime.d.ts +4 -0
  45. package/dist/config/runtime.js +53 -5
  46. package/dist/index.d.ts +65 -1
  47. package/dist/index.js +123 -3
  48. package/dist/nav/index.d.ts +83 -0
  49. package/dist/nav/index.js +9 -0
  50. package/dist/nav/registry.d.ts +15 -0
  51. package/dist/nav/registry.js +42 -0
  52. package/dist/nav/resolver.d.ts +8 -0
  53. package/dist/nav/resolver.js +192 -0
  54. package/dist/nav/workspace.d.ts +18 -0
  55. package/dist/nav/workspace.js +103 -0
  56. package/dist/pipeline/styles.js +7 -35
  57. package/dist/pipeline/token-transformers.d.ts +45 -0
  58. package/dist/pipeline/token-transformers.js +71 -0
  59. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
  60. package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
  61. package/dist/registry/asset-storage/define.d.ts +10 -0
  62. package/dist/registry/asset-storage/define.js +13 -0
  63. package/dist/registry/asset-storage/resolve.d.ts +45 -0
  64. package/dist/registry/asset-storage/resolve.js +36 -0
  65. package/dist/registry/asset-storage/types.d.ts +51 -0
  66. package/dist/registry/asset-storage/types.js +9 -0
  67. package/dist/registry/assets/layout.d.ts +22 -0
  68. package/dist/registry/assets/layout.js +30 -0
  69. package/dist/registry/assets/sets.d.ts +18 -0
  70. package/dist/registry/assets/sets.js +44 -0
  71. package/dist/registry/assets/transfer.d.ts +45 -0
  72. package/dist/registry/assets/transfer.js +11 -0
  73. package/dist/registry/checkout/assets.d.ts +16 -0
  74. package/dist/registry/checkout/assets.js +150 -0
  75. package/dist/registry/checkout/index.d.ts +33 -0
  76. package/dist/registry/checkout/index.js +610 -0
  77. package/dist/registry/checkout/tokens.d.ts +15 -0
  78. package/dist/registry/checkout/tokens.js +171 -0
  79. package/dist/registry/client.d.ts +61 -0
  80. package/dist/registry/client.js +211 -0
  81. package/dist/registry/connection.d.ts +30 -0
  82. package/dist/registry/connection.js +36 -0
  83. package/dist/registry/db/adapter.d.ts +43 -0
  84. package/dist/registry/db/adapter.js +59 -0
  85. package/dist/registry/db/client.d.ts +35 -0
  86. package/dist/registry/db/client.js +94 -0
  87. package/dist/registry/db/index.d.ts +5 -0
  88. package/dist/registry/db/index.js +24 -0
  89. package/dist/registry/db/migrate.d.ts +23 -0
  90. package/dist/registry/db/migrate.js +64 -0
  91. package/dist/registry/db/schema.d.ts +4869 -0
  92. package/dist/registry/db/schema.js +347 -0
  93. package/dist/registry/path.d.ts +4 -0
  94. package/dist/registry/path.js +31 -0
  95. package/dist/registry/publish/artifacts.d.ts +16 -0
  96. package/dist/registry/publish/artifacts.js +132 -0
  97. package/dist/registry/publish/assets.d.ts +16 -0
  98. package/dist/registry/publish/assets.js +129 -0
  99. package/dist/registry/publish/errors.d.ts +5 -0
  100. package/dist/registry/publish/errors.js +26 -0
  101. package/dist/registry/publish/index.d.ts +27 -0
  102. package/dist/registry/publish/index.js +126 -0
  103. package/dist/registry/publish/package.d.ts +27 -0
  104. package/dist/registry/publish/package.js +257 -0
  105. package/dist/registry/publish/publish-build.d.ts +12 -0
  106. package/dist/registry/publish/publish-build.js +43 -0
  107. package/dist/registry/publish/tokens.d.ts +18 -0
  108. package/dist/registry/publish/tokens.js +112 -0
  109. package/dist/registry/tokens/sets.d.ts +59 -0
  110. package/dist/registry/tokens/sets.js +109 -0
  111. package/dist/registry/tokens/transfer.d.ts +48 -0
  112. package/dist/registry/tokens/transfer.js +10 -0
  113. package/dist/registry/transfer.d.ts +75 -0
  114. package/dist/registry/transfer.js +11 -0
  115. package/dist/store/filesystem-assets.d.ts +17 -0
  116. package/dist/store/filesystem-assets.js +147 -0
  117. package/dist/store/filesystem.d.ts +76 -0
  118. package/dist/store/filesystem.js +208 -0
  119. package/dist/store/index.d.ts +20 -0
  120. package/dist/store/index.js +38 -0
  121. package/dist/store/registry.d.ts +77 -0
  122. package/dist/store/registry.js +350 -0
  123. package/dist/store/source-files.d.ts +24 -0
  124. package/dist/store/source-files.js +147 -0
  125. package/dist/store/types.d.ts +185 -0
  126. package/dist/store/types.js +12 -0
  127. package/dist/transformers/plugins/csf-render.js +17 -8
  128. package/dist/transformers/plugins/handlebars-previews.js +10 -3
  129. package/dist/transformers/plugins/ssr-render.js +116 -49
  130. package/dist/transformers/preview/component/api.js +3 -1
  131. package/dist/transformers/preview/component/artifacts.js +8 -1
  132. package/dist/transformers/preview/component/builder.js +13 -5
  133. package/dist/transformers/preview/component/css.js +9 -1
  134. package/dist/transformers/preview/component/javascript.d.ts +9 -3
  135. package/dist/transformers/preview/component/javascript.js +45 -17
  136. package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
  137. package/dist/transformers/preview/component/shared-artifacts.js +92 -0
  138. package/dist/transformers/preview/component.js +5 -2
  139. package/dist/transformers/preview/pattern/builder.js +28 -9
  140. package/dist/transformers/preview/pattern/html.d.ts +13 -10
  141. package/dist/transformers/preview/pattern/html.js +62 -27
  142. package/dist/transformers/preview/types.d.ts +38 -0
  143. package/dist/transformers/utils/handlebars.d.ts +3 -0
  144. package/dist/transformers/utils/handlebars.js +19 -5
  145. package/dist/types/config.d.ts +118 -25
  146. package/dist/utils/menu-shell.d.ts +60 -0
  147. package/dist/utils/menu-shell.js +221 -0
  148. package/dist/utils/stable-stringify.d.ts +2 -0
  149. package/dist/utils/stable-stringify.js +8 -0
  150. package/docs/cli.md +4 -2
  151. package/drizzle/0000_init_registry_storage.sql +95 -0
  152. package/drizzle/0001_add_review_metadata.sql +2 -0
  153. package/drizzle/0002_add_pages.sql +28 -0
  154. package/drizzle/0003_add_tokens.sql +29 -0
  155. package/drizzle/0004_add_assets.sql +37 -0
  156. package/drizzle/meta/0000_snapshot.json +642 -0
  157. package/drizzle/meta/0001_snapshot.json +654 -0
  158. package/drizzle/meta/0002_snapshot.json +835 -0
  159. package/drizzle/meta/0003_snapshot.json +1023 -0
  160. package/drizzle/meta/0004_snapshot.json +1252 -0
  161. package/drizzle/meta/_journal.json +41 -0
  162. package/drizzle.config.ts +16 -0
  163. package/package.json +10 -1
  164. package/src/app/components/Component/ComponentLists.tsx +102 -102
  165. package/src/app/components/Component/Preview.tsx +3 -2
  166. package/src/app/components/Component/RegistryActions.tsx +150 -0
  167. package/src/app/components/Foundations/DisplayLogo.tsx +5 -3
  168. package/src/app/components/Layout/Header.tsx +21 -3
  169. package/src/app/components/Layout/Main.tsx +11 -3
  170. package/src/app/components/Navigation/MainNav.tsx +4 -2
  171. package/src/app/components/Navigation/MobileNav.tsx +4 -2
  172. package/src/app/components/Navigation/SideNav.tsx +46 -11
  173. package/src/app/components/NotFound.tsx +24 -0
  174. package/src/app/components/context/NavProvider.tsx +71 -0
  175. package/src/app/components/old/Header.tsx +3 -3
  176. package/src/app/components/util/index.ts +203 -380
  177. package/src/app/components/util/useCollectionAssets.ts +125 -0
  178. package/src/app/components/util/useFoundationTokens.ts +67 -0
  179. package/src/app/generated/default-pages.json +1 -0
  180. package/src/app/generated/nav-shell.json +1 -0
  181. package/src/app/lib/api/query.ts +6 -0
  182. package/src/app/lib/api/redact.ts +46 -0
  183. package/src/app/lib/asset-storage.ts +81 -0
  184. package/src/app/lib/docs-api/artifacts.ts +116 -0
  185. package/src/app/lib/docs-api/backend.ts +151 -0
  186. package/src/app/lib/docs-api/errors.ts +52 -0
  187. package/src/app/lib/docs-api/handler.ts +34 -0
  188. package/src/app/lib/docs-api/index.ts +7 -0
  189. package/src/app/lib/docs-api/records.ts +189 -0
  190. package/src/app/lib/docs-api/registry.ts +130 -0
  191. package/src/app/lib/docs-api/runtime-config.ts +173 -0
  192. package/src/app/lib/docs-api/serve.ts +37 -0
  193. package/src/app/lib/docs-api/token-detail.ts +19 -0
  194. package/src/app/lib/registry-api/allowlist.ts +163 -0
  195. package/src/app/lib/registry-api/asset-transfer.ts +363 -0
  196. package/src/app/lib/registry-api/auth.ts +68 -0
  197. package/src/app/lib/registry-api/entity-routes.ts +197 -0
  198. package/src/app/lib/registry-api/errors.ts +51 -0
  199. package/src/app/lib/registry-api/files.ts +135 -0
  200. package/src/app/lib/registry-api/handler.ts +96 -0
  201. package/src/app/lib/registry-api/index.ts +4 -0
  202. package/src/app/lib/registry-api/meta.ts +87 -0
  203. package/src/app/lib/registry-api/revalidate.ts +36 -0
  204. package/src/app/lib/registry-api/store.ts +348 -0
  205. package/src/app/lib/registry-api/token-transfer.ts +231 -0
  206. package/src/app/lib/registry-api/transfer.ts +513 -0
  207. package/src/app/lib/registry-api/validation.ts +74 -0
  208. package/src/app/lib/registry-connection.ts +58 -0
  209. package/src/app/lib/utils.ts +14 -0
  210. package/src/app/next.config.mjs +72 -13
  211. package/src/app/pages/[...slug]/index.tsx +74 -20
  212. package/src/app/pages/_app.tsx +5 -2
  213. package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
  214. package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
  215. package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
  216. package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
  217. package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
  218. package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
  219. package/src/app/pages/api/docs/components/[id].ts +24 -0
  220. package/src/app/pages/api/docs/components.json.ts +12 -0
  221. package/src/app/pages/api/docs/nav.json.ts +48 -0
  222. package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
  223. package/src/app/pages/api/docs/patterns/[id].ts +24 -0
  224. package/src/app/pages/api/docs/patterns.json.ts +12 -0
  225. package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
  226. package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
  227. package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
  228. package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
  229. package/src/app/pages/api/registry/components/[id].ts +20 -0
  230. package/src/app/pages/api/registry/components/index.ts +19 -0
  231. package/src/app/pages/api/registry/health.ts +20 -0
  232. package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
  233. package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
  234. package/src/app/pages/api/registry/patterns/[id].ts +20 -0
  235. package/src/app/pages/api/registry/patterns/index.ts +19 -0
  236. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
  237. package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
  238. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
  239. package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
  240. package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
  241. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
  242. package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
  243. package/src/app/pages/assets/fonts.tsx +41 -17
  244. package/src/app/pages/assets/index.tsx +2 -2
  245. package/src/app/pages/assets/logos.tsx +2 -2
  246. package/src/app/pages/foundations/colors.tsx +18 -8
  247. package/src/app/pages/foundations/effects.tsx +18 -8
  248. package/src/app/pages/foundations/icons/[name]/index.tsx +26 -14
  249. package/src/app/pages/foundations/icons/index.tsx +13 -9
  250. package/src/app/pages/foundations/index.tsx +1 -1
  251. package/src/app/pages/foundations/logo.tsx +7 -4
  252. package/src/app/pages/foundations/typography.tsx +19 -9
  253. package/src/app/pages/index.tsx +1 -1
  254. package/src/app/pages/system/component/[component]/index.tsx +87 -17
  255. package/src/app/pages/system/index.tsx +13 -4
  256. package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
  257. package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
  258. package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
  259. package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
  260. package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
  261. package/src/app/tsconfig.json +2 -3
  262. package/src/app-builder/build.ts +555 -11
  263. package/src/app-builder/client-config.ts +61 -4
  264. package/src/app-builder/config-diff/snapshot.ts +1 -11
  265. package/src/app-builder/index.ts +1 -1
  266. package/src/app-builder/nav-shell.ts +74 -0
  267. package/src/app-builder/static-export.ts +215 -0
  268. package/src/app-builder/vercel-output.ts +297 -0
  269. package/src/app-builder/watchers/utils.ts +2 -0
  270. package/src/app-builder/watchers.ts +149 -25
  271. package/src/app-builder/websocket.ts +2 -2
  272. package/src/artifacts/README.md +14 -0
  273. package/src/artifacts/index.ts +17 -0
  274. package/src/artifacts/types.ts +146 -0
  275. package/src/artifacts/url.ts +90 -0
  276. package/src/cache/build-cache.ts +47 -2
  277. package/src/commands/build/app.ts +1 -2
  278. package/src/commands/build/index.ts +61 -0
  279. package/src/commands/checkout/index.ts +66 -0
  280. package/src/commands/db/migrate.ts +25 -0
  281. package/src/commands/index.ts +8 -0
  282. package/src/commands/publish/index.ts +63 -0
  283. package/src/config/defaults.ts +15 -0
  284. package/src/config/loader.ts +10 -1
  285. package/src/config/normalizers/declaration.ts +3 -0
  286. package/src/config/normalizers/page.ts +45 -0
  287. package/src/config/normalizers/pattern.ts +4 -1
  288. package/src/config/runtime.ts +48 -2
  289. package/src/index.ts +126 -3
  290. package/src/nav/index.ts +94 -0
  291. package/src/nav/registry.ts +54 -0
  292. package/src/nav/resolver.ts +217 -0
  293. package/src/nav/workspace.ts +87 -0
  294. package/src/pipeline/styles.ts +4 -35
  295. package/src/pipeline/token-transformers.ts +97 -0
  296. package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
  297. package/src/registry/asset-storage/define.ts +15 -0
  298. package/src/registry/asset-storage/resolve.ts +72 -0
  299. package/src/registry/asset-storage/types.ts +46 -0
  300. package/src/registry/assets/layout.ts +38 -0
  301. package/src/registry/assets/sets.ts +48 -0
  302. package/src/registry/assets/transfer.ts +50 -0
  303. package/src/registry/checkout/assets.ts +126 -0
  304. package/src/registry/checkout/index.ts +596 -0
  305. package/src/registry/checkout/tokens.ts +152 -0
  306. package/src/registry/client.ts +234 -0
  307. package/src/registry/connection.ts +50 -0
  308. package/src/registry/db/adapter.ts +74 -0
  309. package/src/registry/db/client.ts +88 -0
  310. package/src/registry/db/index.ts +35 -0
  311. package/src/registry/db/migrate.ts +56 -0
  312. package/src/registry/db/schema.ts +444 -0
  313. package/src/registry/path.ts +26 -0
  314. package/src/registry/publish/artifacts.ts +147 -0
  315. package/src/registry/publish/assets.ts +129 -0
  316. package/src/registry/publish/errors.ts +34 -0
  317. package/src/registry/publish/index.ts +128 -0
  318. package/src/registry/publish/package.ts +272 -0
  319. package/src/registry/publish/publish-build.ts +51 -0
  320. package/src/registry/publish/tokens.ts +119 -0
  321. package/src/registry/tokens/sets.ts +132 -0
  322. package/src/registry/tokens/transfer.ts +53 -0
  323. package/src/registry/transfer.ts +82 -0
  324. package/src/store/filesystem-assets.ts +152 -0
  325. package/src/store/filesystem.ts +237 -0
  326. package/src/store/index.ts +62 -0
  327. package/src/store/registry.ts +334 -0
  328. package/src/store/source-files.ts +155 -0
  329. package/src/store/types.ts +210 -0
  330. package/src/transformers/plugins/csf-render.ts +31 -7
  331. package/src/transformers/plugins/handlebars-previews.ts +22 -3
  332. package/src/transformers/plugins/ssr-render.ts +145 -57
  333. package/src/transformers/preview/component/api.ts +2 -1
  334. package/src/transformers/preview/component/artifacts.ts +10 -1
  335. package/src/transformers/preview/component/builder.ts +9 -2
  336. package/src/transformers/preview/component/css.ts +10 -1
  337. package/src/transformers/preview/component/javascript.ts +55 -20
  338. package/src/transformers/preview/component/shared-artifacts.ts +146 -0
  339. package/src/transformers/preview/component.ts +5 -2
  340. package/src/transformers/preview/pattern/builder.ts +33 -7
  341. package/src/transformers/preview/pattern/html.ts +71 -27
  342. package/src/transformers/preview/types.ts +42 -0
  343. package/src/transformers/utils/handlebars.ts +31 -5
  344. package/src/types/config.ts +123 -25
  345. package/src/utils/menu-shell.ts +241 -0
  346. package/src/utils/stable-stringify.ts +7 -0
  347. package/templates/default/package.json.tpl +1 -1
  348. package/handoff.config.js +0 -90
  349. package/src/app/components/ComponentSearch.tsx +0 -93
@@ -7,9 +7,15 @@ import { buildPatterns } from '../pipeline/patterns';
7
7
  import processComponents from '../transformers/preview/component/builder';
8
8
  import { buildMainCss } from '../transformers/preview/component/css';
9
9
  import { buildMainJS } from '../transformers/preview/component/javascript';
10
+ import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryAdapter } from '../registry/db/adapter';
11
+ import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
12
+ import type { RuntimeMode } from '../types/config';
10
13
  import { Logger } from '../utils/logger';
11
14
  import { generateTokensApi, persistClientConfig } from './client-config';
15
+ import { generateDefaultPages, generateNavShell } from './nav-shell';
12
16
  import { getAppPath, syncPublicFiles } from './paths';
17
+ import { materializeDocsReadModel, validateReferencedArtifacts } from './static-export';
18
+ import { getVercelOutputPath, writeRegistryVercelOutput, writeStaticVercelOutput } from './vercel-output';
13
19
  import {
14
20
  WatcherState,
15
21
  getRuntimeComponentsPathsToWatch,
@@ -17,14 +23,97 @@ import {
17
23
  watchComponentDirectories,
18
24
  watchGlobalEntries,
19
25
  watchPages,
26
+ watchPatternDirectories,
20
27
  watchPublicDirectory,
21
28
  watchRuntimeComponents,
22
29
  watchRuntimeConfiguration,
23
30
  } from './watchers';
24
31
  import { createWebSocketServer } from './websocket';
25
32
 
33
+ /**
34
+ * Resolved build target. Bare `handoff-app build` resolves to `static`. The
35
+ * target — not `NODE_ENV` — drives whether Next runs a static export, so a future non-static
36
+ * `next build` path (registry) does not get conflated with static export.
37
+ */
38
+ export type BuildTarget = 'static' | 'registry';
39
+
40
+ /** The default build target when none is supplied on the CLI. */
41
+ export const DEFAULT_BUILD_TARGET: BuildTarget = 'static';
42
+
43
+ /**
44
+ * Resolved packaging axis — orthogonal to {@link BuildTarget}. It
45
+ * selects *how* the build is packaged, not *what* is built:
46
+ * - `standalone` → the existing sites-directory deliverable (`out/<projectId>` for static, the
47
+ * Node standalone bundle `out/registry` for registry).
48
+ * - `vercel` → the Vercel Build Output API directory (`.vercel/output`) at the repo root.
49
+ *
50
+ * `--package` never implies a {@link BuildTarget}; it is optional and additive. When omitted, the
51
+ * effective package is target-specific (`standalone` for both targets) so existing behavior is
52
+ * unchanged.
53
+ */
54
+ export type BuildPackage = 'standalone' | 'vercel';
55
+
56
+ /**
57
+ * An expected build configuration / flow failure surfaced to the CLI — an invalid `(target,
58
+ * package)` combination, an unsupported runtime mode for the target, or a not-yet-available
59
+ * deliverable. These are actionable user-facing conditions, not bugs, so the CLI prints the message
60
+ * on its own (no stack trace). Genuine/unexpected failures stay plain `Error`s and keep their stack.
61
+ */
62
+ export class HandoffBuildError extends Error {
63
+ constructor(message: string) {
64
+ super(message);
65
+ this.name = 'HandoffBuildError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Resolve and validate the effective `(target, package)` pair.
71
+ *
72
+ * `--package` is optional and never implies a target. When omitted the effective package is
73
+ * `standalone` for both targets, preserving today's `out/<projectId>` / `out/registry` deliverables.
74
+ * The one rejected combination is `static + standalone`: a static snapshot has no server to package
75
+ * as a Node standalone bundle.
76
+ */
77
+ const resolveBuildPackage = (target: BuildTarget, buildPackage?: BuildPackage): BuildPackage => {
78
+ const resolved: BuildPackage = buildPackage ?? 'standalone';
79
+ if (target === 'static' && buildPackage === 'standalone') {
80
+ throw new HandoffBuildError(
81
+ 'Cannot package the static target as a Node standalone bundle (`--target static --package standalone`). ' +
82
+ 'A static snapshot has no server to run; omit `--package` for the static export, or use ' +
83
+ '`--package vercel` for the Vercel Build Output API, or build `--target registry` for the standalone server bundle.'
84
+ );
85
+ }
86
+ return resolved;
87
+ };
88
+
26
89
  const escapeForSingleQuotedJsString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
27
90
 
91
+ /**
92
+ * Guard the static build target against a registry-only runtime configuration. A static export
93
+ * builds and serves the local workspace; a `runtime.mode: 'registry'` project has no local workspace
94
+ * to export, so the build fails clearly rather than producing an empty or misleading site.
95
+ */
96
+ const assertStaticBuildAllowed = (handoff: Handoff): void => {
97
+ const mode = handoff.config?.runtime?.mode ?? 'workspace';
98
+ if (mode === 'registry') {
99
+ throw new HandoffBuildError(
100
+ 'Cannot run the static build target with a registry-only runtime (runtime.mode: "registry"). ' +
101
+ 'The static target builds and exports the local workspace; switch runtime.mode to "workspace" ' +
102
+ 'or package the registry app with `handoff-app build --target registry`.'
103
+ );
104
+ }
105
+ };
106
+
107
+ /**
108
+ * Output directory for the packaged registry app. Lives under the configurable sites output
109
+ * directory (`sitesOutputDirectory`, default `out`) alongside the static target's `out/<projectId>`
110
+ * deliverable — both build targets emit to one discoverable, gitignored output root, keeping the
111
+ * package's `.handoff` for internal build/staging only. The registry artifact is a single
112
+ * deployable, so it is not keyed by project id.
113
+ */
114
+ export const getRegistryBuildOutputPath = (handoff: Handoff): string =>
115
+ path.resolve(handoff.workingPath, handoff.sitesDirectory, 'registry');
116
+
28
117
  /**
29
118
  * Performs cleanup of the application directory by removing the existing app directory if it exists.
30
119
  */
@@ -37,22 +126,48 @@ const cleanupAppDirectory = async (handoff: Handoff): Promise<void> => {
37
126
  }
38
127
  };
39
128
 
129
+ /**
130
+ * Options controlling how the project app is staged for a build target.
131
+ */
132
+ interface InitializeProjectAppOptions {
133
+ /**
134
+ * Whether to stage workspace-derived artifacts (generated tokens API + the workspace `public`
135
+ * tree). The static target serves these from the exported site; the registry target must never
136
+ * depend on workspace source, so it skips them and serves everything from the database instead.
137
+ * Defaults to `true` (workspace dev/static behavior).
138
+ */
139
+ includeWorkspaceArtifacts?: boolean;
140
+ /**
141
+ * Force the runtime mode baked into the app bundle, irrespective of the source project's
142
+ * `runtime.mode`. The registry build sets this to `registry` so the packaged artifact always runs
143
+ * as a registry deployment even when built from a workspace-mode project (mode stays config-only
144
+ * at runtime — it is fixed here by the build target, not inferred from the deploy environment).
145
+ */
146
+ runtimeModeOverride?: RuntimeMode;
147
+ }
148
+
40
149
  /**
41
150
  * Prepares the project application by copying source files and configuring Next.js.
42
151
  *
43
152
  * @returns The path to the prepared application directory
44
153
  */
45
- const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
154
+ const initializeProjectApp = async (handoff: Handoff, options: InitializeProjectAppOptions = {}): Promise<string> => {
155
+ const includeWorkspaceArtifacts = options.includeWorkspaceArtifacts ?? true;
156
+ const runtimeMode = options.runtimeModeOverride ?? handoff.config?.runtime?.mode ?? 'workspace';
46
157
  const srcPath = path.resolve(handoff.modulePath, 'src', 'app');
47
158
  const appPath = getAppPath(handoff);
48
159
 
49
- // Publish tokens API
50
- await generateTokensApi(handoff);
160
+ // Publish tokens API (workspace-derived; skipped for the registry target which serves from the DB).
161
+ if (includeWorkspaceArtifacts) {
162
+ await generateTokensApi(handoff);
163
+ }
51
164
 
52
165
  // Prepare project app dir
53
166
  await fs.ensureDir(appPath);
54
167
  await fs.copy(srcPath, appPath, { overwrite: true, filter: (file) => !file.includes('next.config.mjs') });
55
- await syncPublicFiles(handoff);
168
+ if (includeWorkspaceArtifacts) {
169
+ await syncPublicFiles(handoff);
170
+ }
56
171
 
57
172
  // Copy custom theme CSS if it exists in the user's project
58
173
  const customThemePath = path.resolve(handoff.workingPath, 'theme.css');
@@ -80,6 +195,19 @@ const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
80
195
  const escapedModulePath = escapeForSingleQuotedJsString(handoffModulePath);
81
196
  const escapedExportPath = escapeForSingleQuotedJsString(handoffExportPath);
82
197
  const escapedWebsocketPort = escapeForSingleQuotedJsString(String(handoffWebsocketPort));
198
+ // Resolved runtime mode + registry connection inputs (names only) baked into the Next bundle so the
199
+ // deployed registry app resolves its mode/DB env-var name without any build-machine filesystem.
200
+ const escapedRuntimeMode = escapeForSingleQuotedJsString(runtimeMode);
201
+ const escapedRegistryAdapter = escapeForSingleQuotedJsString(resolveRegistryAdapter(handoff.config));
202
+ const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
203
+ const escapedApiTokenEnv = escapeForSingleQuotedJsString(resolveApiTokenEnv(handoff.config));
204
+ // Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
205
+ const assetStorage = resolveAssetStorageFromConfig(handoff.config);
206
+ const escapedAssetStorageAdapter = escapeForSingleQuotedJsString(assetStorage.adapterKind);
207
+ const escapedAssetStorageModule = escapeForSingleQuotedJsString(assetStorage.module ?? '');
208
+ const escapedAssetStorageTokenEnv = escapeForSingleQuotedJsString(assetStorage.tokenEnv);
209
+ const escapedAssetStorageMaxInline = escapeForSingleQuotedJsString(String(assetStorage.maxInlineBytes));
210
+ const escapedAssetStorageOptions = escapeForSingleQuotedJsString(JSON.stringify(assetStorage.options ?? {}));
83
211
  const placeholderValues: Record<string, string> = {
84
212
  '%HANDOFF_PROJECT_ID%': escapedProjectId,
85
213
  '%HANDOFF_APP_BASE_PATH%': escapedAppBasePath,
@@ -87,19 +215,54 @@ const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
87
215
  '%HANDOFF_MODULE_PATH%': escapedModulePath,
88
216
  '%HANDOFF_EXPORT_PATH%': escapedExportPath,
89
217
  '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
218
+ '%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
219
+ '%HANDOFF_REGISTRY_ADAPTER%': escapedRegistryAdapter,
220
+ '%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
221
+ '%HANDOFF_REGISTRY_API_TOKEN_ENV%': escapedApiTokenEnv,
222
+ '%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
223
+ '%HANDOFF_ASSET_STORAGE_MODULE%': escapedAssetStorageModule,
224
+ '%HANDOFF_ASSET_STORAGE_TOKEN_ENV%': escapedAssetStorageTokenEnv,
225
+ '%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%': escapedAssetStorageMaxInline,
226
+ '%HANDOFF_ASSET_STORAGE_OPTIONS%': escapedAssetStorageOptions,
90
227
  };
91
228
  let nextConfigContent = await fs.readFile(nextConfigPath, 'utf-8');
92
229
  for (const [placeholder, value] of Object.entries(placeholderValues)) {
93
230
  nextConfigContent = nextConfigContent.split(placeholder).join(value);
94
231
  }
95
232
  await fs.writeFile(targetPath, nextConfigContent);
233
+
234
+ // Bake the markdown-driven navigation shell before `next build` so the docs read API can import it
235
+ // statically (it is unreadable at runtime in the Vercel registry function). Generated for every
236
+ // target; only registry-mode clients fetch it.
237
+ // Registry builds must not bake workspace pages into the shell — they come from the DB at runtime.
238
+ await generateNavShell(handoff, appPath, runtimeMode !== 'registry');
239
+ await generateDefaultPages(handoff, appPath);
240
+
96
241
  return appPath;
97
242
  };
98
243
 
99
244
  /**
100
- * Build the Next.js documentation application.
245
+ * Build the Next.js documentation application for the resolved target. The `static` target builds
246
+ * and exports the local workspace (default); the `registry` target packages the deployable dynamic
247
+ * registry app via {@link buildRegistryApp}.
101
248
  */
102
- const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<void> => {
249
+ const buildApp = async (
250
+ handoff: Handoff,
251
+ target: BuildTarget = DEFAULT_BUILD_TARGET,
252
+ skipComponents?: boolean,
253
+ buildPackage?: BuildPackage
254
+ ): Promise<void> => {
255
+ // Resolve + validate the (target, package) pair once, before any work.
256
+ const resolvedPackage = resolveBuildPackage(target, buildPackage);
257
+
258
+ if (target === 'registry') {
259
+ await buildRegistryApp(handoff, resolvedPackage);
260
+ return;
261
+ }
262
+
263
+ // A registry-only runtime has no local workspace to export — fail clearly before any work.
264
+ assertStaticBuildAllowed(handoff);
265
+
103
266
  skipComponents = skipComponents ?? false;
104
267
  // Perform cleanup
105
268
  await cleanupAppDirectory(handoff);
@@ -115,13 +278,18 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
115
278
 
116
279
  await persistClientConfig(handoff);
117
280
 
118
- // Build app
281
+ // Fail before export if generated HTML references a required artifact that cannot be materialized.
282
+ validateReferencedArtifacts(handoff);
283
+
284
+ // Build app. The static target drives Next's `output: 'export'` via HANDOFF_BUILD_TARGET so the
285
+ // export gate is tied to the resolved target, not to NODE_ENV.
119
286
  const buildResult = spawn.sync('npx', ['next', 'build'], {
120
287
  cwd: appPath,
121
288
  stdio: ['inherit', 'pipe', 'pipe'],
122
289
  env: {
123
290
  ...process.env,
124
291
  NODE_ENV: 'production',
292
+ HANDOFF_BUILD_TARGET: target,
125
293
  },
126
294
  });
127
295
 
@@ -136,6 +304,19 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
136
304
  throw new Error(errorMsg);
137
305
  }
138
306
 
307
+ // Reproduce the docs read API (`/api/docs/*`) as route-shaped static files in the export output,
308
+ // since `output: 'export'` disables the live API routes.
309
+ const exportDir = path.resolve(appPath, 'out');
310
+ await materializeDocsReadModel(handoff, exportDir);
311
+
312
+ // Final assembly branches on the resolved package. `vercel` lays the materialized export
313
+ // under `.vercel/output/static/` at the repo root (not the sites directory) — a hard Vercel
314
+ // constraint; `standalone` keeps writing the `out/<projectId>` export.
315
+ if (resolvedPackage === 'vercel') {
316
+ await writeStaticVercelOutput(handoff, exportDir);
317
+ return;
318
+ }
319
+
139
320
  // Ensure output root directory exists
140
321
  const outputRoot = path.resolve(handoff.workingPath, handoff.sitesDirectory);
141
322
  await fs.ensureDir(outputRoot);
@@ -147,7 +328,363 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
147
328
  }
148
329
 
149
330
  // Copy the build files into the project output directory
150
- await fs.copy(path.resolve(appPath, 'out'), output);
331
+ await fs.copy(exportDir, output);
332
+ };
333
+
334
+ /**
335
+ * Recursively locate the directory containing the standalone `server.js` entry, used as a fallback
336
+ * when the deterministic (tracing-root-relative) location does not resolve — e.g. if Next changes
337
+ * the standalone layout. Returns the first match (breadth-first), or `null` when none is found.
338
+ */
339
+ const findStandaloneServerDir = (root: string): string | null => {
340
+ if (!fs.existsSync(root)) {
341
+ return null;
342
+ }
343
+ const queue: string[] = [root];
344
+ while (queue.length > 0) {
345
+ const current = queue.shift()!;
346
+ const entries = fs.readdirSync(current, { withFileTypes: true });
347
+ if (entries.some((entry) => entry.isFile() && entry.name === 'server.js')) {
348
+ return current;
349
+ }
350
+ for (const entry of entries) {
351
+ if (entry.isDirectory() && entry.name !== 'node_modules') {
352
+ queue.push(path.join(current, entry.name));
353
+ }
354
+ }
355
+ }
356
+ return null;
357
+ };
358
+
359
+ /**
360
+ * Write a deployment guide next to the packaged registry app describing how to run it on Vercel,
361
+ * a custom Node server, or in a container, and which env vars it requires at deploy time.
362
+ */
363
+ const writeRegistryDeploymentReadme = async (
364
+ outputRoot: string,
365
+ entryRelativePath: string,
366
+ databaseUrlEnv: string
367
+ ): Promise<void> => {
368
+ const readme = `# Handoff Registry App
369
+
370
+ A self-contained, deployable Next.js **standalone** build produced by
371
+ \`handoff-app build --target registry\`. It serves the docs read API (\`/api/docs/*\`) and the
372
+ registry API from a PostgreSQL database and the published docs read-model artifacts — it never
373
+ builds or depends on local workspace component/pattern source, and never runs a static export.
374
+
375
+ This is the [Next.js standalone output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output):
376
+ \`server.js\` and the trimmed \`node_modules\` it needs sit at this directory's root, with \`public/\`
377
+ and \`.next/static/\` already copied alongside so the server serves them.
378
+
379
+ ## Required environment variables (supplied at deploy time, never baked in)
380
+
381
+ - \`${databaseUrlEnv}\` — PostgreSQL/Neon connection string, read at request time.
382
+ - \`HANDOFF_REGISTRY_API_TOKEN\` — bearer token for registry management mutations (if used).
383
+
384
+ ## Database migrations
385
+
386
+ Apply migrations from the \`handoff-app\` CLI as a controlled release step (e.g. in your CI/CD
387
+ pipeline) before deploying new code:
388
+
389
+ \`\`\`bash
390
+ ${databaseUrlEnv}="postgres://…" handoff-app db:migrate
391
+ \`\`\`
392
+
393
+ \`db:migrate\` reads your project config + DB env vars, resolves the same database adapter the app was
394
+ built with, and applies the package-owned migration set. It runs independently of \`build\` and of
395
+ starting the server, so run it as a release/one-shot job.
396
+
397
+ ## Run it (self-hosting)
398
+
399
+ Standalone is the documented artifact for self-hosting on a Node.js server, VPS, Docker, or
400
+ Kubernetes. Start the bundled server from this directory:
401
+
402
+ \`\`\`bash
403
+ ${databaseUrlEnv}="postgres://…" node ${entryRelativePath}
404
+ \`\`\`
405
+
406
+ The server listens on \`PORT\` (default \`3000\`) and \`HOSTNAME\` (default \`localhost\`). **In a
407
+ container, set \`HOSTNAME=0.0.0.0\`** so the server is reachable from outside the container:
408
+
409
+ \`\`\`bash
410
+ PORT=3000 HOSTNAME=0.0.0.0 ${databaseUrlEnv}="postgres://…" node ${entryRelativePath}
411
+ \`\`\`
412
+
413
+ ### Docker (sketch)
414
+
415
+ \`\`\`dockerfile
416
+ FROM node:20-alpine
417
+ WORKDIR /app
418
+ COPY . .
419
+ ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
420
+ EXPOSE 3000
421
+ CMD ["node", "${entryRelativePath}"]
422
+ \`\`\`
423
+
424
+ For multi-instance self-hosting, front \`/_next/static\` with a CDN and configure a shared
425
+ \`cacheHandler\` if you rely on ISR.
426
+
427
+ ## Deploying to Vercel
428
+
429
+ Vercel does **not** use \`output: 'standalone'\` — it ignores that setting and builds with its own
430
+ adapter, so this pre-built bundle is **not** the artifact you deploy there. For Vercel, deploy the
431
+ registry app via Vercel's Git/CLI source build (it produces and serves the dynamic app itself) and
432
+ supply the same environment variables above as project env vars. Use this standalone bundle for
433
+ containers, custom Node servers, and other non-Vercel hosts.
434
+ `;
435
+ await fs.writeFile(path.join(outputRoot, 'README.md'), readme);
436
+ };
437
+
438
+ /**
439
+ * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
440
+ * runtime, given the configured database adapter. `next`/`react`/`react-dom` run the server and React
441
+ * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the driver
442
+ * is adapter-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
443
+ */
444
+ const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
445
+ const base = ['next', 'react', 'react-dom', 'drizzle-orm'];
446
+ const adapter = resolveRegistryAdapter(handoff.config);
447
+ const driver = adapter === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
448
+ // Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
449
+ // Vercel Blob adapter needs `@vercel/blob`; a custom adapter may declare its installed SDK
450
+ // package(s) via `assetStorage.options.sdkModules` so the trace/assertion covers them.
451
+ const assetStorage = resolveAssetStorageFromConfig(handoff.config);
452
+ const storage: string[] = [];
453
+ if (assetStorage.adapterKind === 'vercel-blob') {
454
+ storage.push('@vercel/blob');
455
+ }
456
+ const sdkModules = assetStorage.options?.sdkModules;
457
+ if (Array.isArray(sdkModules)) {
458
+ storage.push(...sdkModules.filter((mod): mod is string => typeof mod === 'string'));
459
+ }
460
+ return [...base, ...driver, ...storage];
461
+ };
462
+
463
+ /**
464
+ * Build-time guard against the empty-`node_modules` regression (the artifact would otherwise boot-fail
465
+ * with `Cannot find module 'next'` only on a real, isolated deploy). After assembly, assert every
466
+ * required runtime module resolves under `<entryDir>/node_modules`. `fs.existsSync` follows symlinks,
467
+ * so this also catches a dangling pnpm symlink (the hoisted link traced without its `.pnpm` target),
468
+ * not just an absent directory.
469
+ *
470
+ * `handoff-app` itself is intentionally not asserted: the app inlines it via the `@handoff` alias and
471
+ * `transpilePackages`, so it is not required by its bare specifier at runtime and may be absent.
472
+ */
473
+ const assertRegistryRuntimeDepsTraced = (handoff: Handoff, entryDir: string): void => {
474
+ const required = getRequiredRegistryRuntimeModules(handoff);
475
+ const missing = required.filter((mod) => !fs.existsSync(path.join(entryDir, 'node_modules', ...mod.split('/'), 'package.json')));
476
+ if (missing.length === 0) {
477
+ return;
478
+ }
479
+ throw new HandoffBuildError(
480
+ `The packaged registry app is missing required runtime dependencies in its bundled node_modules: ` +
481
+ `${missing.join(', ')}. The Next file trace did not capture them, so the artifact would fail at ` +
482
+ `startup with "Cannot find module '${missing[0]}'".\n` +
483
+ `This usually means the runtime dependencies are installed *above* the build's tracing root ` +
484
+ `(\`outputFileTracingRoot\`, currently the project root "${handoff.workingPath}"). In a ` +
485
+ `workspace/monorepo the package manager may hoist them to the monorepo root; the tracing root ` +
486
+ `must be the nearest ancestor of *both* the staged app and the resolved node_modules. Ensure ` +
487
+ `${missing.join(', ')} are installed and resolvable from "${handoff.workingPath}".`
488
+ );
489
+ };
490
+
491
+ /**
492
+ * Lay the Next `standalone` build out as the documented standalone layout under `entryDir`: copy the
493
+ * traced bundle, flatten the package-rooted server entry up to `entryDir`, and copy the untraced
494
+ * static assets + public tree + resolved runtime config beside the server entry. Shared by both
495
+ * registry deliverables — the `standalone` package emits this as `out/registry`, and the `vercel`
496
+ * package emits it inside the Build Output API function directory.
497
+ *
498
+ * @returns the absolute path of the server entry (`<entryDir>/server.js`).
499
+ */
500
+ const assembleRegistryStandalone = async (
501
+ handoff: Handoff,
502
+ appPath: string,
503
+ standaloneRoot: string,
504
+ entryDir: string
505
+ ): Promise<string> => {
506
+ await fs.copy(standaloneRoot, entryDir, { overwrite: true });
507
+
508
+ // Locate the server entry within the assembled package. Standalone tracing is rooted at the
509
+ // consumer project root (`workingPath`, see next.config.mjs), so every traced file is laid out at
510
+ // its path *relative to `workingPath`*. The staged app lives at `<workingPath>/node_modules/
511
+ // handoff-app/.handoff/<projectId>`, so the server entry lands nested at that same relative path
512
+ // *underneath* the traced `node_modules`. Resolve it deterministically; the recursive fallback
513
+ // skips `node_modules` (so it cannot find this nested entry) and only covers a hypothetical
514
+ // non-nested layout.
515
+ const relAppDir = path.relative(handoff.workingPath, appPath);
516
+ let serverDir = path.resolve(entryDir, relAppDir);
517
+ if (!fs.existsSync(path.join(serverDir, 'server.js'))) {
518
+ const located = findStandaloneServerDir(entryDir);
519
+ if (!located) {
520
+ throw new Error(
521
+ `Could not locate the standalone "server.js" in the packaged registry app at "${entryDir}". ` +
522
+ 'The Next standalone layout may have changed.'
523
+ );
524
+ }
525
+ serverDir = located;
526
+ }
527
+
528
+ // The project id (and the `.handoff` staging nesting) are irrelevant to the deployable artifact, so
529
+ // flatten the server entry up to `entryDir` — the traced `node_modules` already live there.
530
+ if (path.resolve(serverDir) !== path.resolve(entryDir)) {
531
+ for (const entry of fs.readdirSync(serverDir)) {
532
+ await fs.move(path.join(serverDir, entry), path.join(entryDir, entry), { overwrite: true });
533
+ }
534
+ // Drop the now-empty staging nesting. The entry nests under `node_modules/handoff-app/.handoff`,
535
+ // so we must NOT remove the first path segment (`node_modules`) — that would delete every traced
536
+ // dependency. Remove only the `.handoff` staging container (the parent of the staged app dir),
537
+ // leaving `node_modules/` and the real `node_modules/handoff-app/` package intact.
538
+ const stagingContainer = path.resolve(entryDir, path.relative(handoff.workingPath, path.dirname(appPath)));
539
+ if (path.basename(stagingContainer) === '.handoff' && fs.existsSync(stagingContainer)) {
540
+ await fs.remove(stagingContainer);
541
+ }
542
+ }
543
+
544
+ // Static assets and the app's public tree are not traced into standalone — copy them next to the
545
+ // server entry where the Next standalone server expects them.
546
+ const staticSrc = path.resolve(appPath, '.next', 'static');
547
+ if (fs.existsSync(staticSrc)) {
548
+ await fs.copy(staticSrc, path.join(entryDir, '.next', 'static'), { overwrite: true });
549
+ }
550
+ const publicSrc = path.resolve(appPath, 'public');
551
+ if (fs.existsSync(publicSrc)) {
552
+ await fs.copy(publicSrc, path.join(entryDir, 'public'), { overwrite: true });
553
+ }
554
+
555
+ // Ship the resolved runtime config beside the entry so the deployed server (cwd = entry dir) can
556
+ // read the full client config; runtime mode + DB env-var name are also baked into the bundle.
557
+ for (const configFile of ['client.config.json', 'runtime.server.json']) {
558
+ const src = path.resolve(appPath, configFile);
559
+ if (fs.existsSync(src)) {
560
+ await fs.copy(src, path.join(entryDir, configFile), { overwrite: true });
561
+ }
562
+ }
563
+
564
+ // Fail loudly at build time if the trace did not capture the runtime deps, rather than shipping a
565
+ // broken artifact that only crashes on an isolated deploy. Covers both deliverables (this is the
566
+ // shared assembly step for `standalone` and `vercel`).
567
+ assertRegistryRuntimeDepsTraced(handoff, entryDir);
568
+
569
+ return path.join(entryDir, 'server.js');
570
+ };
571
+
572
+ /**
573
+ * Package the deployable dynamic registry app.
574
+ *
575
+ * Unlike the static target this never builds or bundles workspace component/pattern source and the
576
+ * resulting artifact has no workspace-source runtime dependency — it ships the app/runtime needed to
577
+ * serve the docs read API and registry API from the database and published docs read-model
578
+ * artifacts. Next always runs a `standalone` build (no static export); the divergence is the
579
+ * final-assembly step driven by `buildPackage`:
580
+ * - `standalone` (default) → the documented Next.js standalone layout under the sites output
581
+ * directory (`<project>/<sitesOutputDirectory>/registry`, default `out/registry`) — a
582
+ * self-hosting artifact for containers, custom Node servers, and VPS, started with
583
+ * `node server.js`.
584
+ * - `vercel` → the Vercel Build Output API directory (`.vercel/output`) at the repo root: the
585
+ * traced bundle wrapped as a Node function plus CDN static assets and a route table.
586
+ *
587
+ * Mode is forced to `registry`; the database adapter is honored from config (single-sourced so build
588
+ * and `db:migrate` never diverge). No connection-string value is ever baked — only env-var *names*.
589
+ */
590
+ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = 'standalone'): Promise<void> => {
591
+ // The registry build target *defines* a registry deployment, so the packaged artifact always runs
592
+ // in registry mode regardless of the source project's `runtime.mode`. Mode stays config-only at
593
+ // runtime — it is fixed here by the build target, never inferred from the deploy environment — so
594
+ // a workspace project can produce a registry app without flipping its own runtime mode.
595
+ const sourceMode = handoff.config?.runtime?.mode ?? 'workspace';
596
+ if (sourceMode !== 'registry') {
597
+ Logger.info(`Source runtime.mode is "${sourceMode}"; forcing "registry" mode in the packaged registry app.`);
598
+ }
599
+
600
+ // The adapter stays single-sourced from config (never flipped by the package flag), so build and
601
+ // `db:migrate` always resolve the same one. `pg` uses long-lived TCP connections that pool poorly on
602
+ // serverless; warn (non-fatally) so the operator points DATABASE_URL at a pooled endpoint or selects
603
+ // the Neon adapter. The connection-string value is not available at build time, so this cannot be
604
+ // validated — it is guidance only and never fails the build.
605
+ if (buildPackage === 'vercel' && resolveRegistryAdapter(handoff.config) === 'pg') {
606
+ Logger.warn(
607
+ 'Registry Vercel build uses the "pg" adapter (standard TCP connections), which pools poorly on ' +
608
+ 'serverless and can exhaust the database connection limit. Point DATABASE_URL at a pooled endpoint ' +
609
+ '(PgBouncer / transaction pooling), or set runtime.registry.database.adapter: "neon" for a ' +
610
+ 'serverless-native HTTP/WebSocket driver. The build will continue.'
611
+ );
612
+ }
613
+
614
+ // Stage the app without any workspace-derived artifacts — the registry serves from the database.
615
+ await cleanupAppDirectory(handoff);
616
+ const appPath = await initializeProjectApp(handoff, {
617
+ includeWorkspaceArtifacts: false,
618
+ runtimeModeOverride: 'registry',
619
+ });
620
+
621
+ // Persist the resolved client/server runtime config so the deployed app reports registry mode and
622
+ // resolves its DB env-var name (the connection string itself is read from that env var at runtime).
623
+ await persistClientConfig(handoff, { runtimeModeOverride: 'registry' });
624
+
625
+ // Remove prior registry outputs *before* tracing. Both deliverables (`out/registry` and
626
+ // `.vercel/output`) live inside the trace root (the project root), and after this fix each contains
627
+ // a full bundled `node_modules`. A stale prior output left in place could be swept into the new
628
+ // trace (bundle bloat / recursive nesting), so clear both up front. The per-deliverable cleanup in
629
+ // the assembly step remains.
630
+ await fs.remove(getRegistryBuildOutputPath(handoff));
631
+ await fs.remove(getVercelOutputPath(handoff));
632
+
633
+ // Build the dynamic app. `output: 'standalone'` (driven by HANDOFF_BUILD_TARGET) traces the runtime
634
+ // and the selected Postgres/Neon driver into a self-contained bundle — never a static export.
635
+ const buildResult = spawn.sync('npx', ['next', 'build'], {
636
+ cwd: appPath,
637
+ stdio: ['inherit', 'pipe', 'pipe'],
638
+ env: {
639
+ ...process.env,
640
+ NODE_ENV: 'production',
641
+ HANDOFF_BUILD_TARGET: 'registry',
642
+ },
643
+ });
644
+
645
+ Logger.childProcessBuffer(buildResult.stdout);
646
+ Logger.childProcessBuffer(buildResult.stderr);
647
+
648
+ if (buildResult.status !== 0) {
649
+ let errorMsg = `Registry app build failed with exit code ${buildResult.status}`;
650
+ if (buildResult.error) {
651
+ errorMsg += `\nSpawn error: ${buildResult.error.message}`;
652
+ }
653
+ throw new Error(errorMsg);
654
+ }
655
+
656
+ // Assemble the deployable package. Standalone tracing is rooted at the package (modulePath), so the
657
+ // app's traced files land under `<relAppDir>` inside the standalone tree; static assets and public
658
+ // files are not traced and must be copied alongside the server entry.
659
+ const standaloneRoot = path.resolve(appPath, '.next', 'standalone');
660
+ if (!fs.existsSync(standaloneRoot)) {
661
+ throw new Error(
662
+ `Registry build did not produce a standalone bundle at "${standaloneRoot}". Ensure the registry ` +
663
+ 'build target sets Next `output: "standalone"`.'
664
+ );
665
+ }
666
+
667
+ // Final assembly branches on the resolved package. Both lay out
668
+ // the same traced standalone bundle; they differ only in where it lands and how it is wrapped.
669
+ if (buildPackage === 'vercel') {
670
+ await writeRegistryVercelOutput(handoff, { appPath, standaloneRoot, assembleStandalone: assembleRegistryStandalone });
671
+ return;
672
+ }
673
+
674
+ // Standalone bundle (default): the documented self-hosting layout under the sites output directory.
675
+ const output = getRegistryBuildOutputPath(handoff);
676
+ await fs.remove(output);
677
+ await fs.ensureDir(output);
678
+ const serverEntry = await assembleRegistryStandalone(handoff, appPath, standaloneRoot, output);
679
+
680
+ // Migrations are intentionally not bundled into the artifact: they are applied from the
681
+ // `handoff-app` source/CLI (`handoff-app db:migrate`) as a controlled release step (e.g. CI/CD),
682
+ // never from the deployed bundle. See the generated README for the migrate-then-deploy flow.
683
+
684
+ const entryRelativePath = path.relative(output, serverEntry).split(path.sep).join('/');
685
+ await writeRegistryDeploymentReadme(output, entryRelativePath, resolveDatabaseUrlEnv(handoff.config));
686
+
687
+ Logger.success(`Packaged registry app at ${output} (start: \`node ${entryRelativePath}\`, migrate: \`handoff-app db:migrate\`).`);
151
688
  };
152
689
 
153
690
  /**
@@ -155,11 +692,12 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
155
692
  * Starts a custom dev server with Handoff-specific watchers and hot-reloading.
156
693
  */
157
694
  export const watchApp = async (handoff: Handoff): Promise<void> => {
158
- // Initial processing of the components with caching enabled
159
- // This will skip rebuilding components whose source files haven't changed
160
- await processComponents(handoff, undefined, undefined, { useCache: true });
695
+ // Build the shared/global artifacts first so component/pattern preview HTML references them only
696
+ // when present, then process components with caching enabled (which skips
697
+ // rebuilding components whose source files haven't changed).
161
698
  await buildMainJS(handoff);
162
699
  await buildMainCss(handoff);
700
+ await processComponents(handoff, undefined, undefined, { useCache: true });
163
701
 
164
702
  // Build patterns after components are ready
165
703
  await buildPatterns(handoff);
@@ -190,6 +728,8 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
190
728
  env: {
191
729
  ...process.env,
192
730
  NODE_ENV: 'development',
731
+ // Workspace dev is never a static export, even if a static target leaked into the environment.
732
+ HANDOFF_BUILD_TARGET: '',
193
733
  },
194
734
  });
195
735
  Logger.pipeChildStreams(nextProcess.stdout, nextProcess.stderr);
@@ -224,12 +764,14 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
224
764
  runtimeComponentsWatcher: null,
225
765
  runtimeConfigurationWatcher: null,
226
766
  componentDirectoriesWatcher: null,
767
+ patternDirectoriesWatcher: null,
227
768
  };
228
769
 
229
770
  watchPublicDirectory(handoff, wss, state, chokidarConfig);
230
771
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
231
772
  watchRuntimeConfiguration(handoff, state);
232
773
  watchComponentDirectories(handoff, state, chokidarConfig);
774
+ watchPatternDirectories(handoff, state, chokidarConfig);
233
775
  watchGlobalEntries(handoff, state, chokidarConfig);
234
776
  watchPages(handoff, chokidarConfig);
235
777
  };
@@ -260,6 +802,8 @@ export const devApp = async (handoff: Handoff): Promise<void> => {
260
802
  env: {
261
803
  ...process.env,
262
804
  NODE_ENV: 'development',
805
+ // Workspace dev is never a static export, even if a static target leaked into the environment.
806
+ HANDOFF_BUILD_TARGET: '',
263
807
  },
264
808
  });
265
809