canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -3,6 +3,12 @@ import path from 'node:path';
3
3
  import lockfile from 'proper-lockfile';
4
4
  import { getErrorMessage, isNodeError } from './error.js';
5
5
  import { canopyLogWarn } from './logger.js';
6
+ /**
7
+ * When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
8
+ * which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
9
+ * can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
10
+ */
11
+ export const PROVISIONING_LOCK_STALE_MS = 90_000;
6
12
  /**
7
13
  * Shared option set for both provisioning-lock variants.
8
14
  *
@@ -16,12 +22,14 @@ import { canopyLogWarn } from './logger.js';
16
22
  * share one. `realpath: false` because that anchor path does not exist before we create it
17
23
  * (realpath would ENOENT). See docs/concurrency.md ("Anchor path matters").
18
24
  */
19
- function provisioningLockOptions(lockPath, retries, onCompromised) {
25
+ function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
20
26
  return {
21
27
  lockfilePath: lockPath,
22
28
  realpath: false,
23
29
  retries,
24
- stale: 30_000,
30
+ stale: staleMs,
31
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
32
+ update: 15_000,
25
33
  // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
26
34
  // an uncaught exception that kills the process. Call sites are told not to throw (see
27
35
  // OnLockCompromised); this makes it structural rather than a convention, and also covers the
@@ -76,6 +84,15 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
76
84
  }
77
85
  };
78
86
  }
87
+ /**
88
+ * The provisioning lock's name for the branch workspace directory `dirName`, held in that
89
+ * directory's parent. Every party to a branch workspace's provisioning names it through this:
90
+ * the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
91
+ * while it refreshes or rebases.
92
+ */
93
+ export function branchProvisioningLockName(dirName) {
94
+ return `.${dirName}.init.lock`;
95
+ }
79
96
  /**
80
97
  * Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
81
98
  * always call it in a `finally`.
@@ -94,9 +111,9 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
94
111
  // Generous, jittered retries: several processes may contend for one workspace (Lambda
95
112
  // containers cold-starting together against one EFS root), and the holder can take seconds to
96
113
  // init plus clone/push. `randomize` de-syncs the herd so a waiter is not perpetually colliding
97
- // on the same tick. `stale` stays modest because proper-lockfile auto-refreshes a live holder's
98
- // lock, so it expires only when a process actually dies.
99
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
114
+ // on the same tick. A live holder's lock never goes stale, because proper-lockfile refreshes it;
115
+ // it expires only when a process actually dies.
116
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
100
117
  return releaseIgnoringAlreadyReleased(release, lockPath);
101
118
  }
102
119
  /**
@@ -104,17 +121,61 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
104
121
  * request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
105
122
  * budget waits minutes for a live provisioner; an admin request must fail fast instead.
106
123
  *
107
- * `stale: 30_000` is unchanged from the patient variant: a genuinely stale lock (holder crashed
108
- * more than 30s ago) is still taken over normally. Only the RETRY loop for live contention is
109
- * removed, not the staleness recovery a caller depends on -- see branch-health.ts's [H1]
110
- * freshness rail, which reads this lock's mtime before an admin purge/repair proceeds.
124
+ * Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
125
+ * over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
126
+ * caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
127
+ * before an admin purge/repair proceeds.
111
128
  *
112
129
  * Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
113
130
  * translate that into a 409.
131
+ *
132
+ * @param staleMs how old the marker must look before this caller takes it over; defaults to
133
+ * {@link PROVISIONING_LOCK_STALE_MS}
134
+ * @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
135
+ * branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
136
+ * parent fails with ENOENT instead.
114
137
  */
115
- export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised) {
116
- await fs.mkdir(lockTargetDir, { recursive: true });
138
+ export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
139
+ if (createParents) {
140
+ await fs.mkdir(lockTargetDir, { recursive: true });
141
+ }
142
+ else {
143
+ await fs.mkdir(lockTargetDir).catch((err) => {
144
+ if (!isNodeError(err) || err.code !== 'EEXIST')
145
+ throw err;
146
+ });
147
+ }
117
148
  const lockPath = path.join(lockTargetDir, lockName);
118
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
149
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
119
150
  return releaseIgnoringAlreadyReleased(release, lockPath);
120
151
  }
152
+ const sleep = (ms) => new Promise((resolve) => {
153
+ setTimeout(resolve, ms);
154
+ });
155
+ /**
156
+ * {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
157
+ * milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
158
+ * {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
159
+ * whole timeout; this gives up instead.
160
+ *
161
+ * Retries only on `ELOCKED`, like the content-write lock's bounded wait.
162
+ *
163
+ * @throws the last `ELOCKED` error once the budget is spent
164
+ */
165
+ export async function acquireProvisioningLockWithin(lockTargetDir, lockName, waitMs, onCompromised) {
166
+ const startedAt = Date.now();
167
+ for (let attempt = 0;; attempt++) {
168
+ try {
169
+ return await tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised);
170
+ }
171
+ catch (err) {
172
+ if (!isNodeError(err) || err.code !== 'ELOCKED')
173
+ throw err;
174
+ const remaining = waitMs - (Date.now() - startedAt);
175
+ if (remaining <= 0)
176
+ throw err;
177
+ const base = Math.min(50 * 2 ** attempt, 500);
178
+ await sleep(Math.min(base * (0.5 + Math.random()), remaining));
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,25 @@
1
+ interface PhaseTotal {
2
+ count: number;
3
+ ms: number;
4
+ }
5
+ /** Record `fn` as `phase` in the active request scope; outside one, just run it. */
6
+ export declare function timeRequestPhase<T>(phase: string, fn: () => Promise<T>): Promise<T>;
7
+ /**
8
+ * Whether a request scope is open here.
9
+ * @internal Exported for tests.
10
+ */
11
+ export declare function isRequestTimingScopeActive(): boolean;
12
+ /** Name the matched route (a pattern, never raw path segments) for the summary line. */
13
+ export declare function setRequestTimingRoute(route: string): void;
14
+ /**
15
+ * The summary line's message, after the logger's timestamp/category prefix:
16
+ * `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
17
+ * @internal Exported for tests.
18
+ */
19
+ export declare function formatRequestTimingSummary(method: string, route: string, status: number | 'error', totalMs: number, phases: ReadonlyMap<string, PhaseTotal>): string;
20
+ /**
21
+ * Run one API request inside a timing scope and log its summary line when it settles,
22
+ * thrown or not. `statusOf` reads the status from the result.
23
+ */
24
+ export declare function runWithRequestTiming<T>(method: string, fn: () => Promise<T>, statusOf: (result: T) => number): Promise<T>;
25
+ export {};
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Per-request phase timing for the API handler, enabled by `CANOPYCMS_DEBUG=true`.
3
+ *
4
+ * `runWithRequestTiming` opens a request scope in AsyncLocalStorage and, when the request
5
+ * finishes, emits ONE summary line: method, route pattern, status, total, and every phase
6
+ * recorded inside the scope. `timeRequestPhase` records a phase into whichever scope is
7
+ * active. Scopes are per async context, so concurrent requests in one process never share
8
+ * or overwrite a span, and a phase reached outside any request (the worker, a build, a
9
+ * page render) just runs its function.
10
+ *
11
+ * Disabled, `runWithRequestTiming` calls straight through without opening a scope, so each
12
+ * `timeRequestPhase` costs one `getStore()` that returns undefined.
13
+ *
14
+ * A phase started inside another records under the joined name (`route>settingsRoot`), so
15
+ * nesting stays visible and nested time is never added to its parent's siblings. A phase
16
+ * run several times in one request accumulates, and the line shows the count. `untimed` is
17
+ * the total minus the top-level phases, which run sequentially in the handler.
18
+ *
19
+ * Server-only: `node:async_hooks`.
20
+ */
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+ import { createDebugLogger } from './debug.js';
23
+ const storage = new AsyncLocalStorage();
24
+ const log = createDebugLogger({ prefix: 'CanopyCMS' });
25
+ /** Read per call, like DebugLogger, so toggling the env var needs no restart. */
26
+ function isRequestTimingEnabled() {
27
+ return process.env.CANOPYCMS_DEBUG === 'true';
28
+ }
29
+ /** Record `fn` as `phase` in the active request scope; outside one, just run it. */
30
+ export async function timeRequestPhase(phase, fn) {
31
+ const parent = storage.getStore();
32
+ if (!parent)
33
+ return fn();
34
+ const key = parent.path ? `${parent.path}>${phase}` : phase;
35
+ // Created at START so the summary lists phases in the order they began.
36
+ let total = parent.phases.get(key);
37
+ if (!total) {
38
+ total = { count: 0, ms: 0 };
39
+ parent.phases.set(key, total);
40
+ }
41
+ const start = performance.now();
42
+ try {
43
+ return await storage.run({ ...parent, path: key }, fn);
44
+ }
45
+ finally {
46
+ total.count += 1;
47
+ total.ms += performance.now() - start;
48
+ }
49
+ }
50
+ /**
51
+ * Whether a request scope is open here.
52
+ * @internal Exported for tests.
53
+ */
54
+ export function isRequestTimingScopeActive() {
55
+ return storage.getStore() !== undefined;
56
+ }
57
+ /** Name the matched route (a pattern, never raw path segments) for the summary line. */
58
+ export function setRequestTimingRoute(route) {
59
+ const scope = storage.getStore();
60
+ if (scope)
61
+ scope.request.route = route;
62
+ }
63
+ /**
64
+ * The summary line's message, after the logger's timestamp/category prefix:
65
+ * `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
66
+ * @internal Exported for tests.
67
+ */
68
+ export function formatRequestTimingSummary(method, route, status, totalMs, phases) {
69
+ let topLevelMs = 0;
70
+ const parts = [];
71
+ for (const [name, { count, ms }] of phases) {
72
+ if (!name.includes('>'))
73
+ topLevelMs += ms;
74
+ parts.push(`${name}=${Math.round(ms)}${count > 1 ? `(x${count})` : ''}`);
75
+ }
76
+ parts.push(`untimed=${Math.max(0, Math.round(totalMs - topLevelMs))}`);
77
+ return `${method} ${route} ${status} ${Math.round(totalMs)}ms | ${parts.join(' ')}`;
78
+ }
79
+ /**
80
+ * Run one API request inside a timing scope and log its summary line when it settles,
81
+ * thrown or not. `statusOf` reads the status from the result.
82
+ */
83
+ export async function runWithRequestTiming(method, fn, statusOf) {
84
+ if (!isRequestTimingEnabled())
85
+ return fn();
86
+ const scope = {
87
+ phases: new Map(),
88
+ path: '',
89
+ request: { route: '(unmatched)' },
90
+ };
91
+ const start = performance.now();
92
+ let status = 'error';
93
+ try {
94
+ const result = await storage.run(scope, fn);
95
+ status = statusOf(result);
96
+ return result;
97
+ }
98
+ finally {
99
+ log.debug('timing', formatRequestTimingSummary(method, scope.request.route, status, performance.now() - start, scope.phases));
100
+ }
101
+ }
@@ -55,36 +55,22 @@ export declare function neutralizeImplicitOffOrigin(url: string): string;
55
55
  * this file either way, so the two surfaces cannot drift into disagreeing about which schemes
56
56
  * are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
57
57
  *
58
- * `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
59
- * is blast radius rather than taste:
60
- *
61
- * - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
62
- * `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
63
- * spelling as an intentionally-supported off-site pointer, so rejecting it here would break
64
- * a working configuration. A bad value costs a broken `<img>`.
65
- * - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
66
- * not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
67
- * resolves to http or https depending on the scheme of whichever editor page happens to
68
- * issue the upload, so the stored config does not determine where a live presigned credential
69
- * and the user's file bytes are sent. That is the same principle every other rule here
70
- * enforces — a config value must describe what actually happens.
71
- *
72
- * Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
73
- * accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
74
- * browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
75
- * objection to `//host` is that it is undetermined, not that it might be insecure.
58
+ * A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
59
+ * it resolves to http or https depending on the page issuing the request, so the stored config
60
+ * does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
61
+ * file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
62
+ * endpoint is a real need, and a browser on an https editor blocks the mixed-content request
63
+ * anyway, so it fails closed.
76
64
  *
77
65
  * Every spelling that READS as site-relative while redefining the authority — `/\host`,
78
- * `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
66
+ * `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
79
67
  * to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
80
68
  * different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
81
69
  * are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
82
70
  * accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
83
71
  * standalone and against any base.
84
72
  */
85
- export declare function isHttpUrlOrSameOriginPath(value: string, opts?: {
86
- allowProtocolRelative?: boolean;
87
- }): boolean;
73
+ export declare function isHttpUrlOrSameOriginPath(value: string): boolean;
88
74
  /**
89
75
  * Sanitize an untrusted URL for use in `href` attributes.
90
76
  *
@@ -90,34 +90,22 @@ export function neutralizeImplicitOffOrigin(url) {
90
90
  * this file either way, so the two surfaces cannot drift into disagreeing about which schemes
91
91
  * are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
92
92
  *
93
- * `allowProtocolRelative` exists because the two callers genuinely differ, and the difference
94
- * is blast radius rather than taste:
95
- *
96
- * - A READ-side prefix (`media.publicBaseUrl`, joined onto `/assets/…`) may legitimately be
97
- * `//cdn.example.com`. `utils/url-prefix.ts`'s `isAbsoluteUrl` documents that literal
98
- * spelling as an intentionally-supported off-site pointer, so rejecting it here would break
99
- * a working configuration. A bad value costs a broken `<img>`.
100
- * - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
101
- * not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
102
- * resolves to http or https depending on the scheme of whichever editor page happens to
103
- * issue the upload, so the stored config does not determine where a live presigned credential
104
- * and the user's file bytes are sent. That is the same principle every other rule here
105
- * enforces — a config value must describe what actually happens.
106
- *
107
- * Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
108
- * accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
109
- * browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
110
- * objection to `//host` is that it is undetermined, not that it might be insecure.
93
+ * A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
94
+ * it resolves to http or https depending on the page issuing the request, so the stored config
95
+ * does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
96
+ * file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
97
+ * endpoint is a real need, and a browser on an https editor blocks the mixed-content request
98
+ * anyway, so it fails closed.
111
99
  *
112
100
  * Every spelling that READS as site-relative while redefining the authority — `/\host`,
113
- * `\\host`, `\/host`, `///host` — is rejected for both, in both modes. Those look site-relative
101
+ * `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
114
102
  * to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
115
103
  * different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
116
104
  * are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
117
105
  * accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
118
106
  * standalone and against any base.
119
107
  */
120
- export function isHttpUrlOrSameOriginPath(value, opts = {}) {
108
+ export function isHttpUrlOrSameOriginPath(value) {
121
109
  if (value === '')
122
110
  return false;
123
111
  // Reject ASCII control characters and spaces ANYWHERE in the value, not only at the edges.
@@ -132,7 +120,7 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
132
120
  }
133
121
  // A query or fragment is meaningless on both sides this predicate serves. S3's POST Object
134
122
  // takes no query parameters and a browser never transmits a fragment; and on the read side a
135
- // prefix carrying either produces `https://cdn.example.com/?x=1/assets/…` once joined.
123
+ // prefix carrying either produces `https://cdn.example.com/?x=1/…` once joined.
136
124
  if (value.includes('?') || value.includes('#'))
137
125
  return false;
138
126
  // A backslash is never legitimate in a configured URL, and it is not merely cosmetic: WHATWG
@@ -169,13 +157,8 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
169
157
  return false;
170
158
  }
171
159
  }
172
- // A LITERAL `//host` is the only off-origin spelling that can be intentional, and only for
173
- // callers that opt in. `value[2] !== '/'` matters: `///x` and `////x` are also "implicitly
174
- // off-origin" and also start with `//`, but resolve to a host named `x` rather than to a
175
- // path — so they must not ride in on the opt-in.
176
- if (isImplicitlyOffOrigin(value)) {
177
- return opts.allowProtocolRelative === true && value.startsWith('//') && value[2] !== '/';
178
- }
160
+ if (isImplicitlyOffOrigin(value))
161
+ return false;
179
162
  // Site-relative, with exactly one leading slash. The second clause also covers `//`, which
180
163
  // `isImplicitlyOffOrigin` reports as false only because `new URL('//', base)` throws.
181
164
  return value.startsWith('/') && value[1] !== '/';
@@ -119,3 +119,33 @@ export declare function sanitizeUnprefixedPath(url: string): string;
119
119
  * neutralizing it would silently rewrite a legitimate protocol-relative CDN base into a path.
120
120
  */
121
121
  export declare function joinUrlPrefix(prefix: string | undefined, path: string): string;
122
+ /**
123
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
124
+ *
125
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
126
+ * `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
127
+ * Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
128
+ * 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
129
+ * ending `.ext`.
130
+ *
131
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
132
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
133
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
134
+ * with a trailing slash" means and breaks the URL).
135
+ */
136
+ export declare function withTrailingSlash(path: string): string;
137
+ /**
138
+ * Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
139
+ * `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
140
+ * but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
141
+ * absolute URL's origin, and any query or fragment, are kept as they are.
142
+ */
143
+ export declare function matchTrailingSlash(url: string, trailingSlash: boolean): string;
144
+ /**
145
+ * Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
146
+ * `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
147
+ * expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
148
+ * The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
149
+ * browser.
150
+ */
151
+ export declare function readTrailingSlashEnv(): boolean;
@@ -156,3 +156,64 @@ export function joinUrlPrefix(prefix, path) {
156
156
  : `/${trimmedPrefix}`;
157
157
  return `${normalizedPrefix}${normalizedPath}`;
158
158
  }
159
+ /**
160
+ * Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
161
+ *
162
+ * Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
163
+ * `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
164
+ * Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
165
+ * 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
166
+ * ending `.ext`.
167
+ *
168
+ * A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
169
+ * and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
170
+ * `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
171
+ * with a trailing slash" means and breaks the URL).
172
+ */
173
+ export function withTrailingSlash(path) {
174
+ const splitIndex = path.search(/[?#]/);
175
+ const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
176
+ const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
177
+ const withLeading = base.startsWith('/') ? base : `/${base}`;
178
+ if (withLeading === '/' || withLeading.endsWith('/'))
179
+ return withLeading + suffix;
180
+ const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
181
+ if (lastSegment.includes('.'))
182
+ return withLeading + suffix;
183
+ return `${withLeading}/${suffix}`;
184
+ }
185
+ // Linear: `[^/?#]*` cannot overlap the literal `//` before it, so there is one way to match.
186
+ // eslint-disable-next-line security/detect-unsafe-regex
187
+ const URL_ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/?#]*/i;
188
+ /**
189
+ * Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
190
+ * `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
191
+ * but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
192
+ * absolute URL's origin, and any query or fragment, are kept as they are.
193
+ */
194
+ export function matchTrailingSlash(url, trailingSlash) {
195
+ const origin = isAbsoluteUrl(url) ? (URL_ORIGIN.exec(url)?.[0] ?? '') : '';
196
+ const rest = url.slice(origin.length);
197
+ if (trailingSlash)
198
+ return origin + withTrailingSlash(rest);
199
+ const splitIndex = rest.search(/[?#]/);
200
+ const base = splitIndex === -1 ? rest : rest.slice(0, splitIndex);
201
+ const suffix = splitIndex === -1 ? '' : rest.slice(splitIndex);
202
+ const trimmed = stripTrailingSlashes(base);
203
+ return origin + (trimmed || (origin ? '' : '/')) + suffix;
204
+ }
205
+ /**
206
+ * Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
207
+ * `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
208
+ * expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
209
+ * The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
210
+ * browser.
211
+ */
212
+ export function readTrailingSlashEnv() {
213
+ try {
214
+ return process.env.CANOPY_TRAILING_SLASH === 'true';
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Print a reconciled YAML document by editing the source text it was parsed from, so the bytes
3
+ * the reconciler did not change stay as the author wrote them.
4
+ *
5
+ * `Document.toString()` re-emits every scalar from its VALUE: folded and long plain scalars are
6
+ * re-wrapped at the library's line width, so the first CMS save of a hand-written file rewrites
7
+ * lines nobody edited. Here a clean subtree is copied verbatim from the source, and only a slot
8
+ * the reconciler touched is re-rendered, at the column it already occupies:
9
+ *
10
+ * - **Map**: the reconciler keeps retained pairs in source order and appends new ones, so a map
11
+ * becomes a list of local edits — delete a dropped pair's lines, re-render a pair whose value
12
+ * was replaced, recurse into a value edited in place, insert new pairs after the last item.
13
+ * - **Sequence**: items can be reordered, so the item region is rebuilt as a concatenation of
14
+ * per-item chunks, each one either an original item's source (recursively patched) or a freshly
15
+ * rendered item.
16
+ *
17
+ * A chunk is an item's lines plus the comment and blank lines above it, which is where `yaml`
18
+ * attaches those comments (the item's `commentBefore`), so a chunk moves and dies with exactly
19
+ * the comments the reconciler moves and drops. Above a nested collection's FIRST item they
20
+ * belong to the collection instead, and stay put; above the root map's first key they are the
21
+ * key's (`ownLeadingLines`).
22
+ *
23
+ * Anything this does not handle returns `undefined` from that collection, and the parent
24
+ * re-renders the whole slot instead; only at the root does that reach the caller, which falls
25
+ * back to the reconciled document as `yaml` prints it. Every splice is also re-parsed and
26
+ * checked ({@link printsAs}) before it is returned, so a wrong splice costs formatting, never
27
+ * data.
28
+ */
29
+ import { Document, YAMLMap, YAMLSeq } from 'yaml';
30
+ type Collection = YAMLMap<unknown, unknown> | YAMLSeq<unknown>;
31
+ /** One parsed item: a pair for a map, the item node for a sequence. */
32
+ interface SnapshotItem {
33
+ readonly item: unknown;
34
+ /** A map pair's value as parsed; the reconciler reassigns `pair.value`. */
35
+ readonly value: unknown;
36
+ }
37
+ /** The parsed document's shape, taken before the reconciler runs. */
38
+ export interface SourceSnapshot {
39
+ /** Every collection's items exactly as parsed. */
40
+ readonly items: ReadonlyMap<Collection, readonly SnapshotItem[]>;
41
+ /** The source uses anchors or aliases; an edit through an alias is not local to one slot. */
42
+ readonly anchored: boolean;
43
+ }
44
+ export declare function snapshotDocument(doc: Document): SourceSnapshot;
45
+ /**
46
+ * `printed` (a `toString()`) written with `raw`'s line endings: CRLF when `raw` is consistently
47
+ * CRLF, else LF. Either way without the stray `\r` that comments parsed from CRLF keep, so a
48
+ * whole-file re-print does not leave the file with mixed line endings.
49
+ */
50
+ export declare function withSourceLineEndings(printed: string, raw: string): string;
51
+ /**
52
+ * The source text of `raw` with only the reconciler's changes applied, or undefined when the
53
+ * document holds a construct this does not splice or the splice fails its self-check.
54
+ * `replaced` maps each node the reconciler created for an existing slot back to the node it
55
+ * replaced; `printed` is the reconciled document's `toString()`.
56
+ */
57
+ export declare function spliceSource(raw: string, doc: Document, snapshot: SourceSnapshot, replaced: WeakMap<object, unknown>, printed: string): string | undefined;
58
+ export {};