emdash 0.5.0 → 0.7.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 (252) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
  4. package/dist/apply-5uslYdUu.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +203 -33
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +30 -4
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +11 -4
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +467 -186
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +17 -9
  22. package/dist/astro/types.d.mts.map +1 -1
  23. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  24. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  25. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  26. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  27. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  28. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  29. package/dist/chunks-HGz06Soa.mjs +19 -0
  30. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  31. package/dist/cli/index.mjs +12 -11
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/client/cf-access.d.mts +1 -1
  34. package/dist/client/index.d.mts +1 -1
  35. package/dist/client/index.mjs +1 -1
  36. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  37. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  38. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  39. package/dist/connection-2igzM-AT.mjs.map +1 -0
  40. package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
  41. package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
  42. package/dist/database/instrumentation.d.mts +45 -0
  43. package/dist/database/instrumentation.d.mts.map +1 -0
  44. package/dist/database/instrumentation.mjs +61 -0
  45. package/dist/database/instrumentation.mjs.map +1 -0
  46. package/dist/db/index.d.mts +3 -3
  47. package/dist/db/index.mjs +1 -1
  48. package/dist/db/index.mjs.map +1 -1
  49. package/dist/db/libsql.d.mts +1 -1
  50. package/dist/db/postgres.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts +1 -1
  52. package/dist/db-errors-D0UT85nC.mjs +41 -0
  53. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  54. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  55. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  56. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  57. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  58. package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
  59. package/dist/index-De6_Xv3v.d.mts.map +1 -0
  60. package/dist/index.d.mts +11 -11
  61. package/dist/index.mjs +23 -21
  62. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  63. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  64. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  65. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  66. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  67. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  68. package/dist/media/index.d.mts +1 -1
  69. package/dist/media/index.mjs +1 -1
  70. package/dist/media/local-runtime.d.mts +7 -7
  71. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  72. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  73. package/dist/page/index.d.mts +11 -2
  74. package/dist/page/index.d.mts.map +1 -1
  75. package/dist/page/index.mjs +23 -1
  76. package/dist/page/index.mjs.map +1 -1
  77. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  78. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  79. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  80. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  81. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  82. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  83. package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
  84. package/dist/query-g4Ug-9j9.mjs.map +1 -0
  85. package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
  86. package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
  87. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  88. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  89. package/dist/request-cache-DiR961CV.mjs +79 -0
  90. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  91. package/dist/request-context.d.mts +19 -16
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs.map +1 -1
  94. package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
  95. package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
  96. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  97. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  98. package/dist/runtime.d.mts +6 -6
  99. package/dist/runtime.mjs +1 -1
  100. package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
  101. package/dist/search-B0effn3j.mjs.map +1 -0
  102. package/dist/seed/index.d.mts +2 -2
  103. package/dist/seed/index.mjs +10 -9
  104. package/dist/seo/index.d.mts +1 -1
  105. package/dist/storage/local.d.mts +1 -1
  106. package/dist/storage/local.mjs +1 -1
  107. package/dist/storage/s3.d.mts +1 -1
  108. package/dist/storage/s3.mjs +1 -1
  109. package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
  110. package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
  111. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  112. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  113. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  114. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  115. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  116. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  117. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  118. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  119. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  120. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  121. package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
  122. package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
  123. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  124. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  125. package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
  126. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  127. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  128. package/dist/types-DDS4MxsT.mjs.map +1 -0
  129. package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
  130. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  131. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  132. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  133. package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
  134. package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
  135. package/dist/version-BnTKdfam.mjs +7 -0
  136. package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
  137. package/package.json +10 -5
  138. package/src/after.ts +62 -0
  139. package/src/api/handlers/content.ts +2 -0
  140. package/src/api/handlers/oauth-authorization.ts +2 -32
  141. package/src/api/handlers/oauth-clients.ts +40 -4
  142. package/src/api/handlers/taxonomies.ts +13 -0
  143. package/src/api/oauth/redirect-uri.ts +34 -0
  144. package/src/api/openapi/document.ts +126 -118
  145. package/src/api/schemas/content.ts +8 -0
  146. package/src/api/schemas/media.ts +26 -15
  147. package/src/api/schemas/schema.ts +1 -0
  148. package/src/astro/integration/font-provider.ts +178 -0
  149. package/src/astro/integration/index.ts +44 -0
  150. package/src/astro/integration/routes.ts +6 -0
  151. package/src/astro/integration/runtime.ts +117 -0
  152. package/src/astro/integration/virtual-modules.ts +41 -39
  153. package/src/astro/integration/vite-config.ts +16 -5
  154. package/src/astro/middleware/auth.ts +33 -1
  155. package/src/astro/middleware/request-context.ts +15 -3
  156. package/src/astro/middleware.ts +340 -263
  157. package/src/astro/routes/admin.astro +21 -10
  158. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  159. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  160. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  161. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  162. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  163. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  164. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  167. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  168. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  169. package/src/astro/routes/api/content/[collection]/index.ts +19 -1
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  172. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  173. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
  174. package/src/astro/routes/api/manifest.ts +7 -0
  175. package/src/astro/routes/api/media/upload-url.ts +10 -2
  176. package/src/astro/routes/api/media.ts +10 -7
  177. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  178. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  179. package/src/astro/routes/api/oauth/register.ts +178 -0
  180. package/src/astro/routes/api/oauth/token.ts +15 -0
  181. package/src/astro/routes/api/openapi.json.ts +15 -5
  182. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  183. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  184. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  185. package/src/astro/routes/api/search/index.ts +5 -0
  186. package/src/astro/routes/api/search/suggest.ts +3 -0
  187. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  188. package/src/astro/routes/api/setup/admin.ts +32 -8
  189. package/src/astro/routes/api/setup/index.ts +5 -2
  190. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  191. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
  192. package/src/astro/types.ts +9 -0
  193. package/src/auth/rate-limit.ts +50 -22
  194. package/src/auth/setup-nonce.ts +22 -0
  195. package/src/auth/trusted-proxy.ts +92 -0
  196. package/src/bylines/index.ts +22 -45
  197. package/src/components/EmDashHead.astro +23 -7
  198. package/src/database/connection.ts +23 -1
  199. package/src/database/instrumentation.ts +98 -0
  200. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  201. package/src/database/migrations/runner.ts +2 -0
  202. package/src/database/repositories/content.ts +39 -0
  203. package/src/database/repositories/options.ts +25 -0
  204. package/src/database/repositories/redirect.ts +111 -8
  205. package/src/database/types.ts +9 -0
  206. package/src/db/adapters.ts +15 -0
  207. package/src/emdash-runtime.ts +312 -92
  208. package/src/import/registry.ts +4 -3
  209. package/src/import/ssrf.ts +253 -12
  210. package/src/index.ts +6 -0
  211. package/src/loader.ts +19 -24
  212. package/src/mcp/server.ts +76 -3
  213. package/src/menus/index.ts +6 -3
  214. package/src/page/index.ts +1 -1
  215. package/src/page/seo-contributions.ts +36 -0
  216. package/src/plugins/context.ts +15 -3
  217. package/src/plugins/manager.ts +6 -0
  218. package/src/plugins/request-meta.ts +66 -15
  219. package/src/plugins/routes.ts +3 -1
  220. package/src/query.ts +104 -7
  221. package/src/request-cache.ts +106 -0
  222. package/src/request-context.ts +19 -0
  223. package/src/schema/query.ts +5 -2
  224. package/src/schema/registry.ts +243 -166
  225. package/src/schema/types.ts +13 -2
  226. package/src/schema/zod-generator.ts +4 -0
  227. package/src/search/fts-manager.ts +19 -5
  228. package/src/search/query.ts +4 -3
  229. package/src/seed/apply.ts +41 -1
  230. package/src/settings/index.ts +24 -5
  231. package/src/taxonomies/index.ts +324 -124
  232. package/src/utils/db-errors.ts +46 -0
  233. package/src/virtual-modules.d.ts +31 -10
  234. package/src/visual-editing/toolbar.ts +6 -1
  235. package/src/widgets/index.ts +54 -25
  236. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  237. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  238. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  239. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  240. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  241. package/dist/index-CCWzlriB.d.mts.map +0 -1
  242. package/dist/loader-BYzwzORf.mjs.map +0 -1
  243. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  244. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  245. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  246. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  247. package/dist/search-Cn1SYvYF.mjs.map +0 -1
  248. package/dist/types-C3ronwXb.d.mts.map +0 -1
  249. package/dist/types-DeG21anB.d.mts.map +0 -1
  250. package/dist/types-xxCWI3j0.mjs.map +0 -1
  251. package/dist/validate-Db1yNL3i.d.mts.map +0 -1
  252. package/dist/version-CMMjTuqu.mjs +0 -7
@@ -19,7 +19,9 @@ import type {
19
19
  } from "./astro/integration/runtime.js";
20
20
  import type { EmDashManifest, ManifestCollection } from "./astro/types.js";
21
21
  import { getAuthMode } from "./auth/mode.js";
22
+ import { getTrustedProxyHeaders } from "./auth/trusted-proxy.js";
22
23
  import { isSqlite } from "./database/dialect-helpers.js";
24
+ import { kyselyLogOption } from "./database/instrumentation.js";
23
25
  import { runMigrations } from "./database/migrations/runner.js";
24
26
  import { RevisionRepository } from "./database/repositories/revision.js";
25
27
  import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
@@ -88,6 +90,7 @@ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution
88
90
  }
89
91
  }
90
92
 
93
+ import { after } from "./after.js";
91
94
  import { loadBundleFromR2 } from "./api/handlers/marketplace.js";
92
95
  import { runSystemCleanup } from "./cleanup.js";
93
96
  import {
@@ -153,6 +156,7 @@ import { FTSManager } from "./search/fts-manager.js";
153
156
  const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
154
157
  string: "string",
155
158
  slug: "string",
159
+ url: "url",
156
160
  text: "richText",
157
161
  number: "number",
158
162
  integer: "number",
@@ -286,6 +290,18 @@ export class EmDashRuntime {
286
290
  private enabledPlugins: Set<string>;
287
291
  private pluginStates: Map<string, string>;
288
292
 
293
+ private _cachedManifest: EmDashManifest | null = null;
294
+ private _manifestPromise: Promise<EmDashManifest> | null = null;
295
+ private readonly _manifestCacheKey: string;
296
+
297
+ /**
298
+ * Set to true after FTS indexes have been verified for this worker
299
+ * lifetime so we don't re-scan on every admin request. See
300
+ * ensureSearchHealthy().
301
+ */
302
+ private _searchHealthChecked = false;
303
+ private _searchHealthPromise: Promise<void> | null = null;
304
+
289
305
  /** Current hook pipeline. Use the `hooks` getter for external access. */
290
306
  get hooks(): HookPipeline {
291
307
  return this._hooks;
@@ -344,6 +360,7 @@ export class EmDashRuntime {
344
360
  },
345
361
  runtimeDeps: RuntimeDependencies,
346
362
  pipelineRef: { current: HookPipeline },
363
+ manifestCacheKey: string,
347
364
  ) {
348
365
  this._db = db;
349
366
  this.storage = storage;
@@ -364,6 +381,7 @@ export class EmDashRuntime {
364
381
  this.pipelineFactoryOptions = pipelineFactoryOptions;
365
382
  this.runtimeDeps = runtimeDeps;
366
383
  this.pipelineRef = pipelineRef;
384
+ this._manifestCacheKey = manifestCacheKey;
367
385
  }
368
386
 
369
387
  /**
@@ -413,6 +431,7 @@ export class EmDashRuntime {
413
431
  this.enabledPlugins.delete(pluginId);
414
432
  await this.rebuildHookPipeline();
415
433
  }
434
+ this.invalidateManifest();
416
435
  }
417
436
 
418
437
  /**
@@ -565,36 +584,46 @@ export class EmDashRuntime {
565
584
  /**
566
585
  * Create and initialize the runtime
567
586
  */
568
- static async create(deps: RuntimeDependencies): Promise<EmDashRuntime> {
569
- // Initialize database
570
- const db = await EmDashRuntime.getDatabase(deps);
571
-
572
- // Verify and repair FTS indexes (auto-heal crash corruption)
573
- // FTS5 is SQLite-only; on other dialects, search is a no-op until
574
- // the pluggable SearchProvider work lands.
575
- if (isSqlite(db)) {
587
+ static async create(
588
+ deps: RuntimeDependencies,
589
+ timings?: Array<{ name: string; dur: number; desc?: string }>,
590
+ ): Promise<EmDashRuntime> {
591
+ // Helper: time a phase and push into the shared timings array when
592
+ // provided. Uses performance.now() monotonic across async boundaries.
593
+ // No-op when `timings` wasn't passed (preserves backwards compatibility
594
+ // with callers that don't care about per-phase breakdown).
595
+ const phase = async <T>(name: string, desc: string, fn: () => Promise<T>): Promise<T> => {
596
+ if (!timings) return fn();
597
+ const t0 = performance.now();
576
598
  try {
577
- const ftsManager = new FTSManager(db);
578
- const repaired = await ftsManager.verifyAndRepairAll();
579
- if (repaired > 0) {
580
- console.log(`Repaired ${repaired} corrupted FTS index(es) at startup`);
581
- }
582
- } catch {
583
- // FTS tables may not exist yet (pre-setup). Non-fatal.
599
+ return await fn();
600
+ } finally {
601
+ timings.push({ name, dur: performance.now() - t0, desc });
584
602
  }
585
- }
603
+ };
586
604
 
587
- // Initialize storage
605
+ // Initialize database (connects, runs migrations if needed)
606
+ const db = await phase("rt.db", "DB init + migrations", () => EmDashRuntime.getDatabase(deps));
607
+
608
+ // FTS verify/repair is deferred off the cold-start hot path.
609
+ // See EmDashRuntime.ensureSearchHealthy().
610
+
611
+ // Initialize storage (sync)
588
612
  const storage = EmDashRuntime.getStorage(deps);
589
613
 
590
614
  // Fetch plugin states from database
591
615
  let pluginStates: Map<string, string> = new Map();
592
- try {
593
- const states = await db.selectFrom("_plugin_state").select(["plugin_id", "status"]).execute();
594
- pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
595
- } catch {
596
- // Plugin state table may not exist yet
597
- }
616
+ await phase("rt.plugins", "Plugin states", async () => {
617
+ try {
618
+ const states = await db
619
+ .selectFrom("_plugin_state")
620
+ .select(["plugin_id", "status"])
621
+ .execute();
622
+ pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
623
+ } catch {
624
+ // Plugin state table may not exist yet
625
+ }
626
+ });
598
627
 
599
628
  // Build set of enabled plugins
600
629
  const enabledPlugins = new Set<string>();
@@ -605,21 +634,25 @@ export class EmDashRuntime {
605
634
  }
606
635
  }
607
636
 
608
- // Load site info for plugin context extensions
637
+ // Load site info for plugin context extensions (1 batch query instead of 3)
609
638
  let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined;
610
- try {
611
- const optionsRepo = new OptionsRepository(db);
612
- const siteName = await optionsRepo.get<string>("emdash:site_title");
613
- const siteUrl = await optionsRepo.get<string>("emdash:site_url");
614
- const locale = await optionsRepo.get<string>("emdash:locale");
615
- siteInfo = {
616
- siteName: siteName ?? undefined,
617
- siteUrl: siteUrl ?? undefined,
618
- locale: locale ?? undefined,
619
- };
620
- } catch {
621
- // Options table may not exist yet (pre-setup)
622
- }
639
+ await phase("rt.site", "Site info options", async () => {
640
+ try {
641
+ const optionsRepo = new OptionsRepository(db);
642
+ const siteOpts = await optionsRepo.getMany<string>([
643
+ "emdash:site_title",
644
+ "emdash:site_url",
645
+ "emdash:locale",
646
+ ]);
647
+ siteInfo = {
648
+ siteName: siteOpts.get("emdash:site_title") ?? undefined,
649
+ siteUrl: siteOpts.get("emdash:site_url") ?? undefined,
650
+ locale: siteOpts.get("emdash:locale") ?? undefined,
651
+ };
652
+ } catch {
653
+ // Options table may not exist yet (pre-setup)
654
+ }
655
+ });
623
656
 
624
657
  // Build the full list of pipeline-eligible plugins: all configured
625
658
  // plugins (regardless of current enabled status) plus built-in plugins.
@@ -685,11 +718,15 @@ export class EmDashRuntime {
685
718
  const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions);
686
719
 
687
720
  // Load sandboxed plugins (build-time)
688
- const sandboxedPlugins = await EmDashRuntime.loadSandboxedPlugins(deps, db);
721
+ const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () =>
722
+ EmDashRuntime.loadSandboxedPlugins(deps, db),
723
+ );
689
724
 
690
725
  // Cold-start: load marketplace-installed plugins from site R2
691
726
  if (deps.config.marketplace && storage) {
692
- await EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins);
727
+ await phase("rt.market", "Marketplace plugins", () =>
728
+ EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins),
729
+ );
693
730
  }
694
731
 
695
732
  // Initialize media providers
@@ -707,7 +744,9 @@ export class EmDashRuntime {
707
744
  }
708
745
 
709
746
  // Resolve exclusive hooks — auto-select providers and sync with DB
710
- await EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps);
747
+ await phase("rt.hooks", "Exclusive hook resolution", () =>
748
+ EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps),
749
+ );
711
750
 
712
751
  // ── Email pipeline ───────────────────────────────────────────────
713
752
  // The email pipeline orchestrates beforeSend → deliver → afterSend.
@@ -740,52 +779,84 @@ export class EmDashRuntime {
740
779
  let cronExecutor: CronExecutor | null = null;
741
780
  let cronScheduler: CronScheduler | null = null;
742
781
 
743
- try {
744
- cronExecutor = new CronExecutor(db, invokeCronHook);
745
-
746
- // Recover stale locks from previous crashes
747
- const recovered = await cronExecutor.recoverStaleLocks();
748
- if (recovered > 0) {
749
- console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
750
- }
782
+ await phase("rt.cron", "Cron init (recovery deferred post-response)", async () => {
783
+ try {
784
+ cronExecutor = new CronExecutor(db, invokeCronHook);
785
+
786
+ // Recover stale locks from previous crashes. Pure bookkeeping
787
+ // against the _emdash_cron_tasks table — no request needs the
788
+ // result so we defer it past the response via after(). On
789
+ // Cloudflare this goes into waitUntil (extending the worker
790
+ // lifetime); on Node it's fire-and-forget (the process stays
791
+ // up anyway). Saves one cold-start write per D1 isolate.
792
+ const executorForRecovery = cronExecutor;
793
+ after(async () => {
794
+ try {
795
+ const recovered = await executorForRecovery.recoverStaleLocks();
796
+ if (recovered > 0) {
797
+ console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
798
+ }
799
+ } catch (error) {
800
+ // Keep the `[cron]` prefix so a failure is easy to trace back
801
+ // rather than surfacing as a generic deferred-task error.
802
+ console.error("[cron] Failed to recover stale task locks:", error);
803
+ }
804
+ });
751
805
 
752
- // Detect platform and create appropriate scheduler.
753
- // On Cloudflare Workers, setTimeout is available but unreliable for
754
- // long durations — use PiggybackScheduler as default.
755
- // In Node/Bun, use NodeCronScheduler with real timers.
756
- const isWorkersRuntime =
757
- typeof globalThis.navigator !== "undefined" &&
758
- globalThis.navigator.userAgent === "Cloudflare-Workers";
759
-
760
- if (isWorkersRuntime) {
761
- cronScheduler = new PiggybackScheduler(cronExecutor);
762
- } else {
763
- cronScheduler = new NodeCronScheduler(cronExecutor);
764
- }
806
+ // Detect platform and create appropriate scheduler.
807
+ // On Cloudflare Workers, setTimeout is available but unreliable for
808
+ // long durations — use PiggybackScheduler as default.
809
+ // In Node/Bun, use NodeCronScheduler with real timers.
810
+ const isWorkersRuntime =
811
+ typeof globalThis.navigator !== "undefined" &&
812
+ globalThis.navigator.userAgent === "Cloudflare-Workers";
765
813
 
766
- // Register system cleanup to run alongside each scheduler tick.
767
- // Pass storage so cleanupPendingUploads can delete orphaned files.
768
- cronScheduler.setSystemCleanup(async () => {
769
- try {
770
- await runSystemCleanup(db, storage ?? undefined);
771
- } catch (error) {
772
- // Non-fatal -- individual cleanup failures are already logged
773
- // by runSystemCleanup. This catches unexpected errors.
774
- console.error("[cleanup] System cleanup failed:", error);
814
+ if (isWorkersRuntime) {
815
+ cronScheduler = new PiggybackScheduler(cronExecutor);
816
+ } else {
817
+ cronScheduler = new NodeCronScheduler(cronExecutor);
775
818
  }
776
- });
777
819
 
778
- // Add cron reschedule callback (merges with existing factory options)
779
- pipeline.setContextFactory({
780
- cronReschedule: () => cronScheduler?.reschedule(),
781
- });
820
+ // Register system cleanup to run alongside each scheduler tick.
821
+ // Pass storage so cleanupPendingUploads can delete orphaned files.
822
+ cronScheduler.setSystemCleanup(async () => {
823
+ try {
824
+ await runSystemCleanup(db, storage ?? undefined);
825
+ } catch (error) {
826
+ // Non-fatal -- individual cleanup failures are already logged
827
+ // by runSystemCleanup. This catches unexpected errors.
828
+ console.error("[cleanup] System cleanup failed:", error);
829
+ }
830
+ });
782
831
 
783
- // Start the scheduler
784
- await cronScheduler.start();
785
- } catch (error) {
786
- console.warn("[cron] Failed to initialize cron system:", error);
787
- // Non-fatal — CMS works without cron
788
- }
832
+ // Add cron reschedule callback (merges with existing factory options)
833
+ pipeline.setContextFactory({
834
+ cronReschedule: () => cronScheduler?.reschedule(),
835
+ });
836
+
837
+ // Start the scheduler
838
+ await cronScheduler.start();
839
+ } catch (error) {
840
+ console.warn("[cron] Failed to initialize cron system:", error);
841
+ // Non-fatal — CMS works without cron
842
+ }
843
+ });
844
+
845
+ // SHA of emdash commit + user config that affects the manifest.
846
+ // COMMIT captures emdash code changes; plugin IDs/versions and i18n
847
+ // capture user astro.config changes (e.g. upgrading a plugin package).
848
+ // DB-driven changes (collections, fields, plugin toggle) go through
849
+ // invalidateManifest(). Sorted for stability across nondeterministic
850
+ // plugin ordering.
851
+ const manifestCacheKey = await hashString(
852
+ [
853
+ COMMIT,
854
+ ...deps.plugins.map((p) => `${p.id}@${p.version ?? ""}`).toSorted(),
855
+ ...deps.sandboxedPluginEntries.map((e) => `${e.id}@${e.version}`).toSorted(),
856
+ virtualConfig?.i18n?.defaultLocale ?? "",
857
+ (virtualConfig?.i18n?.locales ?? []).toSorted().join(","),
858
+ ].join("|"),
859
+ );
789
860
 
790
861
  return new EmDashRuntime(
791
862
  db,
@@ -806,6 +877,7 @@ export class EmDashRuntime {
806
877
  pipelineFactoryOptions,
807
878
  deps,
808
879
  pipelineRef,
880
+ manifestCacheKey,
809
881
  );
810
882
  }
811
883
 
@@ -837,12 +909,14 @@ export class EmDashRuntime {
837
909
  * Get or create database instance
838
910
  */
839
911
  private static async getDatabase(deps: RuntimeDependencies): Promise<Kysely<Database>> {
840
- // If a per-request DB override is set (e.g. by the playground middleware
841
- // which runs before the runtime init), use that directly. This allows
842
- // the runtime to initialize against the real DO database instead of
843
- // the dummy singleton dialect.
912
+ // Only use the per-request `ctx.db` when it's an isolated instance
913
+ // (playground / DO preview). Plain D1 Sessions set `ctx.db` on every
914
+ // anonymous request if we captured one of those session-bound
915
+ // Kyselys into the cached runtime, every request would accidentally
916
+ // share one request's session. The configured `deps.createDialect`
917
+ // path gives us a fresh singleton instead.
844
918
  const ctx = getRequestContext();
845
- if (ctx?.db) {
919
+ if (ctx?.dbIsIsolated && ctx.db) {
846
920
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- db in context is typed as unknown to avoid circular deps
847
921
  return ctx.db as Kysely<Database>;
848
922
  }
@@ -878,9 +952,20 @@ export class EmDashRuntime {
878
952
 
879
953
  dbInitPromise = (async () => {
880
954
  const dialect = deps.createDialect(dbConfig.config);
881
- const db = new Kysely<Database>({ dialect });
955
+ const db = new Kysely<Database>({ dialect, log: kyselyLogOption() });
882
956
 
883
- await runMigrations(db);
957
+ const { applied } = await runMigrations(db);
958
+
959
+ // If migrations were applied, the schema changed — clear the
960
+ // DB-persisted manifest cache so getManifest() rebuilds it.
961
+ if (applied.length > 0) {
962
+ try {
963
+ const options = new OptionsRepository(db);
964
+ await options.delete("emdash:manifest_cache");
965
+ } catch {
966
+ // Non-fatal
967
+ }
968
+ }
884
969
 
885
970
  // Auto-seed schema if no collections exist and setup hasn't run.
886
971
  // This covers first-load on sites that skip the setup wizard.
@@ -1142,9 +1227,82 @@ export class EmDashRuntime {
1142
1227
  // =========================================================================
1143
1228
 
1144
1229
  /**
1145
- * Build the manifest (rebuilt on each request for freshness)
1230
+ * Get the manifest, using an in-memory cache with a DB-persisted
1231
+ * fallback for cold starts. Avoids N+1 schema registry queries
1232
+ * on every request.
1233
+ *
1234
+ * Cache is invalidated by invalidateManifest(), called from schema
1235
+ * API routes, MCP server, plugin toggle, and taxonomy def changes.
1146
1236
  */
1147
1237
  async getManifest(): Promise<EmDashManifest> {
1238
+ // When the DB is overridden by an isolated instance (playground /
1239
+ // DO-preview sessions), bypass the module-scoped manifest cache —
1240
+ // its schema may diverge from the configured DB. Plain D1 Sessions
1241
+ // routing does NOT set `dbIsIsolated`, so the cache still applies.
1242
+ if (getRequestContext()?.dbIsIsolated) {
1243
+ return this._buildManifest();
1244
+ }
1245
+
1246
+ if (this._cachedManifest) return this._cachedManifest;
1247
+
1248
+ // DB-persisted cache (1 query instead of N+1 rebuild on cold start).
1249
+ // Keyed by SHA of commit + config to bust on deploys. DB-driven
1250
+ // changes (collections, fields, plugins, taxonomies) go through
1251
+ // invalidateManifest().
1252
+ try {
1253
+ const options = new OptionsRepository(this.db);
1254
+ const cached = await options.get<{ key: string; manifest: EmDashManifest }>(
1255
+ "emdash:manifest_cache",
1256
+ );
1257
+ if (cached && cached.key === this._manifestCacheKey && cached.manifest) {
1258
+ this._cachedManifest = cached.manifest;
1259
+ return cached.manifest;
1260
+ }
1261
+ } catch {
1262
+ // Options table may not exist yet
1263
+ }
1264
+
1265
+ // Full rebuild, then persist. Track which promise is current so
1266
+ // an invalidation during the build can't be overwritten.
1267
+ if (!this._manifestPromise) {
1268
+ let manifestPromise: Promise<EmDashManifest>;
1269
+ const isCurrentLoad = () => this._manifestPromise === manifestPromise;
1270
+ manifestPromise = this._loadManifest(isCurrentLoad);
1271
+ this._manifestPromise = manifestPromise;
1272
+ }
1273
+ return this._manifestPromise;
1274
+ }
1275
+
1276
+ private async _loadManifest(isCurrentLoad: () => boolean): Promise<EmDashManifest> {
1277
+ try {
1278
+ const manifest = await this._buildManifest();
1279
+
1280
+ if (isCurrentLoad()) {
1281
+ this._cachedManifest = manifest;
1282
+
1283
+ try {
1284
+ const options = new OptionsRepository(this.db);
1285
+ await options.set("emdash:manifest_cache", {
1286
+ key: this._manifestCacheKey,
1287
+ manifest,
1288
+ });
1289
+ } catch {
1290
+ // Non-fatal — will just rebuild next time
1291
+ }
1292
+ }
1293
+
1294
+ return manifest;
1295
+ } finally {
1296
+ if (isCurrentLoad()) {
1297
+ this._manifestPromise = null;
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ /**
1303
+ * Build the manifest from database (N+1 collection queries).
1304
+ */
1305
+ private async _buildManifest(): Promise<EmDashManifest> {
1148
1306
  // Build collections from database.
1149
1307
  // Use this.db (ALS-aware getter) so playground mode picks up the
1150
1308
  // per-session DO database instead of the hardcoded singleton.
@@ -1370,11 +1528,72 @@ export class EmDashRuntime {
1370
1528
 
1371
1529
  /**
1372
1530
  * Invalidate cached data derived from the manifest/schema.
1373
- * Called when collections are created, updated, or deleted.
1531
+ * Called when collections, fields, plugins, or taxonomy defs change.
1374
1532
  */
1375
1533
  invalidateManifest(): void {
1376
- // Invalidate the URL pattern cache used by resolveEmDashPath
1534
+ this._cachedManifest = null;
1535
+ this._manifestPromise = null;
1377
1536
  invalidateUrlPatternCache();
1537
+ // Delete DB-persisted cache so the next cold start rebuilds.
1538
+ // Fire-and-forget: in-memory is already cleared for this worker,
1539
+ // DB delete is best-effort for the next cold start.
1540
+ try {
1541
+ const options = new OptionsRepository(this.db);
1542
+ options.delete("emdash:manifest_cache").catch((error) => {
1543
+ console.error("Failed to delete persisted manifest cache", error);
1544
+ });
1545
+ } catch (error) {
1546
+ console.error("Failed to initialize manifest cache invalidation", error);
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * Verify and repair FTS indexes on demand. Runs at most once per worker
1552
+ * lifetime.
1553
+ *
1554
+ * Originally called from `EmDashRuntime.create()`, but on a busy D1 link
1555
+ * (e.g. SIN replica ~80-150ms per query) it added ~1.5s to every cold
1556
+ * start for a modest-sized site — more than every other init phase
1557
+ * combined. Anonymous public reads never touch the search write path,
1558
+ * so the cost isn't paid back for the vast majority of requests.
1559
+ *
1560
+ * Instead, search endpoints call this lazily: the first request that
1561
+ * actually needs the index pays the verify cost (usually fast — no
1562
+ * rebuild needed), everyone else runs cold-free.
1563
+ *
1564
+ * Uses the runtime's singleton database (`this._db`) rather than the
1565
+ * request-scoped DB. Verify reads only, but `rebuildIndex` writes, and
1566
+ * a GET search request on D1 carries a `first-unconstrained` session
1567
+ * that's free to route at a read replica — unsafe for writes. The
1568
+ * singleton always goes through the default binding, which the D1
1569
+ * adapter will promote to `first-primary` for write statements.
1570
+ *
1571
+ * Safe to call concurrently: repeated callers share the same in-flight
1572
+ * promise. Errors are swallowed internally so callers don't need to
1573
+ * defend against FTS not existing yet (pre-setup).
1574
+ */
1575
+ async ensureSearchHealthy(): Promise<void> {
1576
+ if (this._searchHealthChecked) return;
1577
+ if (this._searchHealthPromise) return this._searchHealthPromise;
1578
+ if (!isSqlite(this._db)) {
1579
+ this._searchHealthChecked = true;
1580
+ return;
1581
+ }
1582
+ this._searchHealthPromise = (async () => {
1583
+ try {
1584
+ const ftsManager = new FTSManager(this._db);
1585
+ const repaired = await ftsManager.verifyAndRepairAll();
1586
+ if (repaired > 0) {
1587
+ console.log(`Repaired ${repaired} corrupted FTS index(es)`);
1588
+ }
1589
+ } catch {
1590
+ // FTS tables may not exist yet (pre-setup). Non-fatal.
1591
+ } finally {
1592
+ this._searchHealthChecked = true;
1593
+ this._searchHealthPromise = null;
1594
+ }
1595
+ })();
1596
+ return this._searchHealthPromise;
1378
1597
  }
1379
1598
 
1380
1599
  // =========================================================================
@@ -1862,6 +2081,7 @@ export class EmDashRuntime {
1862
2081
  const routeRegistry = new PluginRouteRegistry({
1863
2082
  db: this.db,
1864
2083
  emailPipeline: this.email ?? undefined,
2084
+ trustedProxyHeaders: getTrustedProxyHeaders(this.config),
1865
2085
  });
1866
2086
  routeRegistry.register(trustedPlugin);
1867
2087
 
@@ -2103,7 +2323,7 @@ export class EmDashRuntime {
2103
2323
 
2104
2324
  try {
2105
2325
  const headers = sanitizeHeadersForSandbox(request.headers);
2106
- const meta = extractRequestMeta(request);
2326
+ const meta = extractRequestMeta(request, this.config);
2107
2327
  const result = await plugin.invokeRoute(routeName, body, {
2108
2328
  url: request.url,
2109
2329
  method: request.method,
@@ -4,7 +4,7 @@
4
4
  * Manages available import sources and provides URL probing.
5
5
  */
6
6
 
7
- import { validateExternalUrl } from "./ssrf.js";
7
+ import { resolveAndValidateExternalUrl } from "./ssrf.js";
8
8
  import type { ImportSource, ProbeResult, SourceProbeResult } from "./types.js";
9
9
 
10
10
  // Regex pattern for URL normalization
@@ -63,8 +63,9 @@ export async function probeUrl(url: string): Promise<ProbeResult> {
63
63
  // Remove trailing slash for consistency
64
64
  normalizedUrl = normalizedUrl.replace(TRAILING_SLASHES_PATTERN, "");
65
65
 
66
- // SSRF: reject internal/private network targets
67
- validateExternalUrl(normalizedUrl);
66
+ // SSRF: reject internal/private network targets. DNS resolution
67
+ // catches hostnames that resolve to private addresses.
68
+ await resolveAndValidateExternalUrl(normalizedUrl);
68
69
 
69
70
  const results: SourceProbeResult[] = [];
70
71
  const urlSources = getUrlSources();