-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathemdash-runtime.ts
More file actions
4414 lines (4055 loc) · 152 KB
/
Copy pathemdash-runtime.ts
File metadata and controls
4414 lines (4055 loc) · 152 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* EmDashRuntime - Core runtime for EmDash CMS
*
* Manages database, storage, plugins (trusted + sandboxed), hooks, and
* provides handlers for content/media operations.
*
* Created once per worker lifetime, cached and reused across requests.
*/
import { Permissions } from "@emdash-cms/auth";
import type { Element } from "@emdash-cms/blocks";
import { Kysely, type Dialect } from "kysely";
import virtualConfig from "virtual:emdash/config";
import { z } from "zod";
import { ErrorCode } from "./api/errors.js";
import { buildManifestCollections } from "./api/handlers/manifest.js";
import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js";
import { validateRev } from "./api/rev.js";
import type {
EmDashConfig,
PluginAdminPage,
PluginDashboardWidget,
} from "./astro/integration/runtime.js";
import type { EmDashManifest } from "./astro/types.js";
import { getAuthMode } from "./auth/mode.js";
import { getTrustedProxyHeaders } from "./auth/trusted-proxy.js";
import type { ContentFieldFilters } from "./content-list-query.js";
import { isSqlite } from "./database/dialect-helpers.js";
import { kyselyLogOption } from "./database/instrumentation.js";
import {
enforceRuntimeMigrationPolicy,
PendingMigrationsError,
type RuntimeMigrationMode,
} from "./database/migrations/policy.js";
import {
ConcurrentMigrationTimeoutError,
MIGRATION_RACE_WAIT_MS,
} from "./database/migrations/runner.js";
import { AuditRepository } from "./database/repositories/audit.js";
import { ContentRepository } from "./database/repositories/content.js";
import { RevisionRepository } from "./database/repositories/revision.js";
import { ContentMutationConflictError } from "./database/repositories/types.js";
import type {
ContentItem as ContentItemInternal,
ContentDateField,
} from "./database/repositories/types.js";
import type { ImageValue } from "./fields/types.js";
import { getI18nConfig } from "./i18n/config.js";
import { repairLocaleCasing } from "./i18n/repair-locale-casing.js";
import { warnAboutUnconfiguredTaxonomyLocales } from "./i18n/taxonomy-locale-diagnostic.js";
import { normalizeMediaValue } from "./media/normalize.js";
import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js";
import { activateMediaUsageCapture } from "./media/usage/activation.js";
import {
deleteContentMediaUsage,
findNonTranslatableSiblingContentIds,
markContentMediaUsageCollectionStale,
refreshContentMediaUsageAfterWrite,
} from "./media/usage/content-refresh.js";
import { processMediaUsageWorkAfterWrite } from "./media/usage/work-processor.js";
import { inspectSandboxHookResult } from "./plugins/sandbox/hook-result.js";
import { createSandboxRunnerOptions } from "./plugins/sandbox/runner-options.js";
import { getSandboxRouteErrorDetails } from "./plugins/sandbox/types.js";
import type {
SandboxedPluginInstance,
SandboxRunner,
SandboxRunnerFactory,
} from "./plugins/sandbox/types.js";
import type {
ActorInfo,
ContentHookEvent,
ResolvedPlugin,
MediaItem,
PluginManifest,
PluginCapability,
PluginStorageConfig,
PluginMcpManifestConfig,
PublicPageContext,
PageMetadataContribution,
PageFragmentContribution,
PortableTextBlockConfig,
FieldWidgetConfig,
SettingField,
UserInfo,
} from "./plugins/types.js";
import { recordSchedulerHeartbeatSafely } from "./scheduler-health.js";
import { primeRegisteredCollections } from "./schema/collection-slugs-cache.js";
import { isMissingTableError } from "./utils/db-errors.js";
import { hashString } from "./utils/hash.js";
import { createInitLock, type InitLock, initWithLock } from "./utils/init-lock.js";
import { createSingleFlightCache, singleFlightCached } from "./utils/single-flight-cache.js";
import { COMMIT, VERSION } from "./version.js";
const LEADING_SLASH_PATTERN = /^\//;
const LOCALE_CASING_REPAIR_OPTION = "emdash:repair_locale_casing";
function getLocaleCasingRepairVersion(locales: readonly string[]): string | null {
if (locales.length === 0) return null;
return `1:${locales.toSorted().join(",")}`;
}
/**
* Parse a JSON column expected to contain an array of strings.
*
* Throws on malformed JSON rather than returning []; callers are responsible
* for deciding how to handle/log the error. Empty string / null inputs return
* [] (they represent "no value"). Non-string array entries are filtered out.
*/
function parseStringArray(raw: string | null | undefined): string[] {
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((v): v is string => typeof v === "string");
}
/** Combined result from a single-pass page contribution collection */
interface PageContributions {
metadata: PageMetadataContribution[];
fragments: PageFragmentContribution[];
}
const VALID_METADATA_KINDS = new Set(["meta", "property", "link", "jsonld"]);
/** Security-critical allowlist for link rel values from sandboxed plugins */
const VALID_LINK_REL = new Set([
"canonical",
"alternate",
"author",
"license",
"nlweb",
"site.standard.document",
]);
/**
* Runtime validation for sandboxed plugin metadata contributions.
* Sandboxed plugins return `unknown` across the RPC boundary — we must
* verify the shape before passing to the metadata collector.
*/
function isValidMetadataContribution(c: unknown): c is PageMetadataContribution {
if (!c || typeof c !== "object" || !("kind" in c)) return false;
const obj = c as Record<string, unknown>;
if (typeof obj.kind !== "string" || !VALID_METADATA_KINDS.has(obj.kind)) return false;
switch (obj.kind) {
case "meta":
return typeof obj.name === "string" && typeof obj.content === "string";
case "property":
return typeof obj.property === "string" && typeof obj.content === "string";
case "link":
return (
typeof obj.href === "string" && typeof obj.rel === "string" && VALID_LINK_REL.has(obj.rel)
);
case "jsonld":
return obj.graph != null && typeof obj.graph === "object";
default:
return false;
}
}
import { after } from "./after.js";
import { maybeRunScheduledBackup } from "./api/handlers/backup.js";
import { loadBundleFromR2 } from "./api/handlers/marketplace.js";
import { runSystemCleanup } from "./cleanup.js";
import {
DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
defaultCommentModerate,
} from "./comments/moderator.js";
import { validateEncryptionKeyAtStartup } from "./config/secrets.js";
import { OptionsRepository } from "./database/repositories/options.js";
import {
handleContentList,
handleContentAuthors,
handleContentGet,
handleContentGetIncludingTrashed,
handleContentCreate,
handleContentUpdate,
handleContentDelete,
handleContentDuplicate,
handleContentRestore,
handleContentPermanentDelete,
handleContentListTrashed,
handleContentCountTrashed,
handleContentPublish,
handleContentUnpublish,
handleContentSchedule,
handleContentUnschedule,
handleContentCountScheduled,
handleContentDiscardDraft,
handleContentCompare,
handleContentTranslations,
handleMediaList,
handleMediaGet,
handleMediaCreate,
handleMediaUpdate,
handleMediaReplaceMetadata,
handleMediaDelete,
handleRevisionList,
handleRevisionGet,
handleRevisionRestore,
SchemaRegistry,
type Database,
type Storage,
} from "./index.js";
import { getDb } from "./loader.js";
import { isRecord } from "./plugin-utils.js";
import { CronExecutor, type InvokeCronHookFn } from "./plugins/cron.js";
import { definePlugin } from "./plugins/define-plugin.js";
import { DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver } from "./plugins/email-console.js";
import { EmailPipeline } from "./plugins/email.js";
import {
createHookPipeline,
resolveExclusiveHooks as resolveExclusiveHooksShared,
type HookPipeline,
} from "./plugins/hooks.js";
import { normalizeManifestRoute } from "./plugins/manifest-schema.js";
import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js";
import {
buildRouteMeta,
parseRouteInput,
PluginRouteRegistry,
toRouteCallerInfo,
type RouteCallerInput,
type RouteMeta,
} from "./plugins/routes.js";
import { isContentSaveRejection } from "./plugins/save-rejection.js";
import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js";
import { resolveManifestRegistryConfig } from "./registry/config.js";
import { requestCached } from "./request-cache.js";
import { getRequestContext } from "./request-context.js";
import { publishDueContent, type PublishedRef } from "./scheduled-publish.js";
import { FTSManager } from "./search/fts-manager.js";
import { invalidateSiteSettingsCache } from "./settings/index.js";
const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision", "actor"]);
const MAX_DRAFT_STAGE_ATTEMPTS = 32;
/**
* Sandboxed plugin entry from virtual module
*/
export interface SandboxedPluginEntry {
id: string;
version: string;
options: Record<string, unknown>;
code: string;
/** Capabilities the plugin requests */
capabilities: PluginCapability[];
/** Allowed hosts for network:fetch */
allowedHosts: string[];
/** Declared storage collections */
storage: PluginStorageConfig;
/** Serialized MCP declarations emitted at plugin build time. */
mcp?: PluginMcpManifestConfig;
/** Route declarations (name + public/permission/cacheControl), used for route auth decisions */
routes?: PluginManifest["routes"];
/** Hook declarations this plugin implements */
hooks?: PluginManifest["hooks"];
/** Admin pages */
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
/** Dashboard widgets */
adminWidgets?: Array<{ id: string; title?: string; size?: string }>;
/** Settings schema for the auto-generated admin settings form */
settingsSchema?: Record<string, SettingField>;
/** Portable Text block types contributed to the editor (declarative Block Kit) */
portableTextBlocks?: PortableTextBlockConfig[];
/** Field widget types contributed for schema-field editing UIs */
fieldWidgets?: FieldWidgetConfig[];
/** Admin entry module */
adminEntry?: string;
/**
* Exclusive hooks this plugin should be auto-selected for.
* Weaker than an existing admin DB selection — config order wins when no selection exists.
*/
preferred?: string[];
}
/**
* Media provider entry from virtual module
*/
export interface MediaProviderEntry {
id: string;
name: string;
icon?: string;
capabilities: MediaProviderCapabilities;
/** Factory function to create the provider instance */
createProvider: (ctx: MediaProviderContext) => MediaProvider;
}
/**
* Context passed to media provider factory functions
*/
export interface MediaProviderContext {
db: Kysely<Database>;
/**
* Resolver for the live connection, preferred over `db` by providers that
* query EmDash's database. Resolves the current request/event-scoped
* connection from ALS so connection-backed adapters (Postgres over
* Hyperdrive) don't reuse the per-isolate singleton's socket across events.
* Providers should resolve per operation rather than capturing `db` once.
* Omitted-safe: falls back to `db` for stateless adapters (D1, Node SQLite).
*/
getDb?: () => Kysely<Database>;
storage: Storage | null;
}
/**
* Builds the timer-based scheduler that drives cron ticks and maintenance.
* Injected via `virtual:emdash/scheduler` so the platform — not core — decides
* whether a long-lived heartbeat exists.
*/
export type CreateSchedulerFn = (executor: CronExecutor) => CronScheduler;
/**
* Dependencies injected from virtual modules (middleware reads these)
*/
export interface RuntimeDependencies {
config: EmDashConfig;
/** Effective migration mode, resolved once by the runtime entrypoint. */
migrationMode?: RuntimeMigrationMode;
plugins: ResolvedPlugin[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createDialect: (config: any) => Dialect;
/**
* Factory for a dialect that batches same-turn reads into one round trip
* ({@link EmDashRuntime.create} uses it for the cold-start read phase).
* Present only on batching backends (D1, DO); absent backends fall back to
* the singleton. Returns a fresh connection each call — it must never be the
* long-lived singleton, whose coalescing buffer would be shared across
* requests.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createCoalescingDialect?: (config: any) => Dialect | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createStorage: ((config: any) => Storage) | null;
sandboxEnabled: boolean;
/** sandbox: false escape hatch - load sandboxed plugins in-process */
sandboxBypassed?: boolean;
/**
* Factory for the timer-based cron/maintenance heartbeat. Supplied by the
* generated `virtual:emdash/scheduler` module: a `NodeCronScheduler` factory
* on long-lived runtimes (Node/Bun), or `null` on serverless adapters where
* an external driver (e.g. the Cloudflare Worker's `scheduled()` Cron
* Trigger) calls `runScheduledTasks()` instead. When absent or null, the
* runtime starts no scheduler. Keeping the platform decision in the
* integration means core has no adapter-specific runtime checks.
*/
createScheduler?: CreateSchedulerFn | null;
/** Media provider entries from virtual module */
mediaProviderEntries?: MediaProviderEntry[];
sandboxedPluginEntries: SandboxedPluginEntry[];
/** Factory function supplied by the active platform adapter. */
createSandboxRunner: SandboxRunnerFactory | null;
}
/**
* Constructor parameters for `EmDashRuntime`.
*
* Production code should use `EmDashRuntime.create()` which discovers and
* loads all parts (database, plugins, hooks, cron, etc.) and then calls the
* constructor. Direct construction is supported for callers that already
* have all the dependencies in hand — for example, integration tests that
* supply a pre-migrated database and an empty plugin set.
*
* Every field corresponds 1:1 to internal state set on the runtime — none of
* these are derived. If you don't have a value for one, see what `create()`
* passes for that field as the canonical default.
*/
export interface EmDashRuntimeParts {
db: Kysely<Database>;
storage: Storage | null;
configuredPlugins: ResolvedPlugin[];
sandboxedPlugins: Map<string, SandboxedPluginInstance>;
sandboxedPluginEntries: SandboxedPluginEntry[];
hooks: HookPipeline;
enabledPlugins: Set<string>;
pluginStates: Map<string, string>;
config: EmDashConfig;
mediaProviders: Map<string, MediaProvider>;
mediaProviderEntries: MediaProviderEntry[];
cronExecutor: CronExecutor | null;
cronScheduler: CronScheduler | null;
emailPipeline: EmailPipeline | null;
allPipelinePlugins: ResolvedPlugin[];
pipelineFactoryOptions: {
db: Kysely<Database>;
getDb?: () => Kysely<Database>;
beforeContentWrite?: () => Promise<void>;
storage?: Storage;
siteInfo?: {
siteName?: string;
siteUrl?: string;
locale?: string;
trailingSlash?: "always" | "never" | "ignore";
};
};
runtimeDeps: RuntimeDependencies;
pipelineRef: { current: HookPipeline };
}
/**
* A `ContentSaveRejectedError` carries a message the plugin wrote for the
* editor; every other exception stays internal and is replaced by a generic
* message so hook internals cannot leak through the API.
*/
function beforeSaveFailure(error: unknown) {
if (isContentSaveRejection(error)) {
return {
success: false as const,
error: { code: ErrorCode.SAVE_REJECTED, message: error.message },
};
}
console.error("EmDash: content:beforeSave hook failed:", error);
return {
success: false as const,
error: {
code: ErrorCode.CONTENT_HOOK_ERROR,
message: "A plugin hook failed while saving content",
},
};
}
/**
* Convert a ContentItem to Record<string, unknown> for hook consumption.
* Hooks receive the full item as a flat record.
*/
function contentItemToRecord(item: ContentItemInternal): Record<string, unknown> {
return { ...item };
}
/**
* Db init lock reclaim deadline. Derived from the migration race wait so
* they can't drift apart: a healthy init can legitimately block for the
* full MIGRATION_RACE_WAIT_MS inside waitForConcurrentMigrator, plus cold
* connect and migrator work, before it should be presumed dead. The outer
* runtime init lock (middleware.ts) must use a strictly larger deadline —
* it wraps create() → getDatabase() → this lock, and equal deadlines would
* let the outer reclaim while the inner is legitimately still working.
*/
export const DB_INIT_DEADLINE_MS = MIGRATION_RACE_WAIT_MS + 20_000;
/**
* Db cache + its init lock live on globalThis behind a Symbol: the bundler
* can duplicate this module across SSR chunks (same reasoning as
* request-cache.ts), and a duplicated cache/lock would mean concurrent
* independent db inits — and duplicate migrators — per isolate.
*/
const DB_HOLDER_KEY = Symbol.for("emdash:db-cache");
interface DbHolder {
cache: Map<string, Kysely<Database>>;
lock: InitLock;
/**
* Recent migration failures, keyed like `cache`. A failed migration is
* near-certain to fail again immediately (schema conflict, broken
* migration), and without this every request in a warm isolate would
* re-attempt it — on Workers with Postgres that stampedes the database
* through the migration advisory lock (#1744). Entries expire after
* DB_INIT_FAILURE_BACKOFF_MS; a successful init clears them.
*/
failures: Map<string, { at: number; message: string }>;
}
const globalSymbolStore = globalThis as Record<symbol, unknown>;
function getDbHolder(): DbHolder {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis symbol slot, written only below
let holder = globalSymbolStore[DB_HOLDER_KEY] as DbHolder | undefined;
if (!holder) {
holder = {
cache: new Map<string, Kysely<Database>>(),
lock: createInitLock(),
failures: new Map(),
};
globalSymbolStore[DB_HOLDER_KEY] = holder;
}
// A holder created by an older copy of this module (dev-server HMR keeps
// globalThis across reloads) may predate the failures map.
holder.failures ??= new Map();
return holder;
}
/**
* After a database init fails (migrations threw), skip re-attempting for
* this long. Cold isolates still get one attempt each, so a transient
* failure heals on its own; a persistently failing migration is retried at
* most once per backoff window per isolate instead of on every request.
*/
const DB_INIT_FAILURE_BACKOFF_MS = 30_000;
/**
* Auto-seed runs at most once per isolate per database. Its lock + "done" set
* live on globalThis (same bundler-duplication reasoning as the db cache) so a
* reclaimed-and-rerun `create()` can't seed a second time concurrently. The
* lock polls rather than sharing a promise, so it is safe to await across a
* cancelled owner in workerd.
*/
const SEED_HOLDER_KEY = Symbol.for("emdash:seed-state");
interface SeedHolder {
done: Set<string>;
lock: InitLock;
}
function getSeedHolder(): SeedHolder {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis symbol slot, written only below
let holder = globalSymbolStore[SEED_HOLDER_KEY] as SeedHolder | undefined;
if (!holder) {
holder = { done: new Set<string>(), lock: createInitLock() };
globalSymbolStore[SEED_HOLDER_KEY] = holder;
}
return holder;
}
const storageCache = new Map<string, Storage>();
const sandboxedPluginCache = new Map<string, SandboxedPluginInstance>();
/**
* Per-tier sets of `${pluginId}:${version}` keys present in
* `sandboxedPluginCache`. Used during sync to know which entries belong
* to which install source so we can invalidate only what belongs to the
* tier currently being synced.
*/
const marketplacePluginKeys = new Set<string>();
const registryPluginKeys = new Set<string>();
/**
* Manifest metadata for runtime-installed sandboxed plugins (marketplace
* and registry both). Keyed by `pluginId`; readers don't care which
* source the plugin came from. Named `marketplace*` for legacy reasons.
*/
const marketplaceManifestCache = new Map<
string,
{
id: string;
version: string;
admin?: {
pages?: PluginAdminPage[];
widgets?: PluginDashboardWidget[];
settingsSchema?: Record<string, SettingField>;
};
mcp?: PluginMcpManifestConfig;
storage?: PluginManifest["storage"];
}
>();
/** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */
const sandboxedRouteMetaCache = new Map<string, Map<string, RouteMeta>>();
let sandboxRunner: SandboxRunner | null = null;
/**
* EmDashRuntime - singleton per worker
*/
export class EmDashRuntime {
/**
* The singleton database instance (worker-lifetime cached).
* Use the `db` getter instead — it checks the request context first
* for per-request overrides (D1 read replica sessions, DO multi-site).
*/
private readonly _db: Kysely<Database>;
readonly storage: Storage | null;
readonly configuredPlugins: ResolvedPlugin[];
readonly sandboxedPlugins: Map<string, SandboxedPluginInstance>;
readonly sandboxedPluginEntries: SandboxedPluginEntry[];
/**
* Schema registry bound to the current request/event-scoped connection.
* Built per access (SchemaRegistry just wraps a db) against `this.db`, the
* ALS-aware getter — never a captured snapshot of the singleton. On a
* connection-backed adapter (Postgres over Hyperdrive) a captured singleton
* would query a socket opened by an earlier event and trip workerd's
* cross-request I/O guard; the catch in handlers like handleContentUpdate
* would then silently treat a revision-enabled collection as non-revisioned
* and write draft edits to live columns. Same reasoning as the per-call
* registry in _buildManifest().
*/
get schemaRegistry(): SchemaRegistry {
return new SchemaRegistry(this.db);
}
private _hooks!: HookPipeline;
readonly config: EmDashConfig;
readonly mediaProviders: Map<string, MediaProvider>;
readonly mediaProviderEntries: MediaProviderEntry[];
readonly cronExecutor: CronExecutor | null;
readonly email: EmailPipeline | null;
private cronScheduler: CronScheduler | null;
private enabledPlugins: Set<string>;
private pluginStates: Map<string, string>;
/**
* Isolate-lifetime guard so FTS indexes are verified at most once per
* worker rather than on every admin request. See ensureSearchHealthy().
* Uses the poison-immune single-flight cache (never a shared awaitable
* promise) so a cancelled first caller can't wedge later ones.
*/
private readonly _searchHealthCache = createSingleFlightCache<void>();
/** Current hook pipeline. Use the `hooks` getter for external access. */
get hooks(): HookPipeline {
return this._hooks;
}
/** All plugins eligible for the hook pipeline (includes built-in plugins).
* Stored so we can rebuild the pipeline when plugins are enabled/disabled. */
private allPipelinePlugins: ResolvedPlugin[];
/** Guards the once-per-process plugin storage-index sync. */
private storageIndexesSynced = false;
/** Factory options for the hook pipeline context factory */
private pipelineFactoryOptions: {
db: Kysely<Database>;
getDb?: () => Kysely<Database>;
beforeContentWrite?: () => Promise<void>;
storage?: Storage;
siteInfo?: {
siteName?: string;
siteUrl?: string;
locale?: string;
trailingSlash?: "always" | "never" | "ignore";
};
};
/** Dependencies needed for exclusive hook resolution */
private runtimeDeps: RuntimeDependencies;
/** Mutable ref for the cron invokeCronHook closure to read the current pipeline */
private pipelineRef!: { current: HookPipeline };
/**
* Get the database instance for the current request.
*
* Checks the ALS-based request context first — middleware sets a
* per-request Kysely instance there for D1 read replica sessions
* or DO preview databases. Falls back to the singleton instance.
*/
get db(): Kysely<Database> {
const ctx = getRequestContext();
if (ctx?.db) {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- db in context is set by middleware with correct type
return ctx.db as Kysely<Database>;
}
return this._db;
}
constructor(parts: EmDashRuntimeParts) {
this._db = parts.db;
this.storage = parts.storage;
this.configuredPlugins = parts.configuredPlugins;
this.sandboxedPlugins = parts.sandboxedPlugins;
this.sandboxedPluginEntries = parts.sandboxedPluginEntries;
this._hooks = parts.hooks;
this.enabledPlugins = parts.enabledPlugins;
this.pluginStates = parts.pluginStates;
this.config = parts.config;
this.mediaProviders = parts.mediaProviders;
this.mediaProviderEntries = parts.mediaProviderEntries;
this.cronExecutor = parts.cronExecutor;
this.cronScheduler = parts.cronScheduler;
this.email = parts.emailPipeline;
this.allPipelinePlugins = parts.allPipelinePlugins;
this.pipelineFactoryOptions = parts.pipelineFactoryOptions;
this.runtimeDeps = parts.runtimeDeps;
this.pipelineRef = parts.pipelineRef;
}
/**
* Get the sandbox runner instance (for marketplace install/update)
*/
getSandboxRunner(): SandboxRunner | null {
return sandboxRunner;
}
/**
* Whether the sandbox bypass mode (sandbox: false) is active.
* Marketplace install/update handlers use this to skip the
* SANDBOX_NOT_AVAILABLE gate, since the bypass path loads
* marketplace plugins in-process via syncMarketplacePlugins().
*/
isSandboxBypassed(): boolean {
return this.runtimeDeps.sandboxBypassed === true;
}
/**
* Publish any content whose scheduled time has passed.
* Returns the items promoted so callers can invalidate their cache tags.
*/
async publishScheduled(): Promise<PublishedRef[]> {
return this.publishScheduledWithFence();
}
private async publishScheduledWithFence(
onPublished?: (refs: PublishedRef[]) => Promise<void>,
): Promise<PublishedRef[]> {
await assertMediaUsageActivationWriteAllowed(this.db);
return publishDueContent(this.db, {
publish: (collection, id, options) => this.handleContentPublish(collection, id, options),
onPublished,
});
}
/**
* Run the full scheduled-maintenance batch: cron tasks, scheduled
* publishing, and system cleanup. For request-less drivers — the
* Cloudflare `scheduled()` handler invokes this from a Cron Trigger.
* (On Node the timer-based scheduler drives the same work itself.)
*
* Each step is independent and non-fatal. Returns the content promoted
* by the publishing sweep so the caller can purge edge-cache tags.
*
* `onPublished` (optional) is awaited after each collection's batch so a
* request-less driver can invalidate edge-cache tags incrementally rather
* than only after the whole sweep — bounding stale-cache exposure if the
* runtime is killed mid-sweep.
*/
async runScheduledTasks(
options: {
onPublished?: (refs: PublishedRef[]) => Promise<void>;
} = {},
): Promise<{ published: PublishedRef[] }> {
if (this.cronExecutor) {
try {
await this.cronExecutor.tick();
} catch (error) {
console.error("[cron] Tick failed:", error);
}
try {
await this.cronExecutor.recoverStaleLocks();
} catch (error) {
console.error("[cron] Stale lock recovery failed:", error);
}
}
let published: PublishedRef[] = [];
try {
published = await this.publishScheduledWithFence(options.onPublished);
} catch (error) {
console.error("[scheduled-publish] Sweep failed:", error);
}
try {
await runSystemCleanup(this.db, this.storage ?? undefined);
} catch (error) {
console.error("[cleanup] System cleanup failed:", error);
}
try {
await this.syncPluginStorageIndexesOnce();
} catch (error) {
console.error("[plugins] Storage index sync failed:", error);
}
// Never throws; no-op unless scheduled backups are enabled and due.
await maybeRunScheduledBackup(this.db, this.storage ?? undefined);
await recordSchedulerHeartbeatSafely(this.db);
return { published };
}
/**
* Materialize plugin-declared storage indexes, once per process.
*
* Called from the scheduler path, not from request handlers — configured
* plugins have no install handler, so the tick is their only sync moment.
*/
async syncPluginStorageIndexesOnce(): Promise<void> {
if (this.storageIndexesSynced) return;
this.storageIndexesSynced = true;
// Sandboxed marketplace/registry plugins never join allPipelinePlugins;
// their manifests are cached at bundle load. Without them, plugins
// installed before this feature shipped would never get their indexes.
await syncDeclaredStorageIndexes(this.db, [
...this.allPipelinePlugins,
...marketplaceManifestCache.values(),
]);
}
/**
* Stop the cron scheduler gracefully.
* Call during worker shutdown or hot-reload.
*/
async stopCron(): Promise<void> {
if (this.cronScheduler) {
await this.cronScheduler.stop();
}
}
/**
* Update in-memory plugin status and rebuild the hook pipeline.
*
* Rebuilding the pipeline ensures disabled plugins' hooks stop firing
* and re-enabled plugins' hooks start firing again without a restart.
* Exclusive hook selections are re-resolved after each rebuild.
*/
async setPluginStatus(pluginId: string, status: "active" | "inactive"): Promise<void> {
this.pluginStates.set(pluginId, status);
if (status === "active") {
this.enabledPlugins.add(pluginId);
await this.rebuildHookPipeline();
await this._hooks.runPluginActivate(pluginId);
} else {
// Fire deactivate on the current pipeline while the plugin is still in it
await this._hooks.runPluginDeactivate(pluginId);
this.enabledPlugins.delete(pluginId);
await this.rebuildHookPipeline();
}
}
/**
* Rebuild the hook pipeline from the current set of enabled plugins.
*
* Filters `allPipelinePlugins` to only those in `enabledPlugins`,
* creates a fresh HookPipeline, re-resolves exclusive hook selections,
* and re-wires the context factory so existing references (cron
* callbacks, email pipeline) use the new pipeline.
*/
private async rebuildHookPipeline(): Promise<void> {
const enabledList = this.allPipelinePlugins.filter((p) => this.enabledPlugins.has(p.id));
const newPipeline = createHookPipeline(enabledList, this.pipelineFactoryOptions);
// Re-resolve exclusive hooks against the new pipeline
await EmDashRuntime.resolveExclusiveHooks(newPipeline, this.db, this.runtimeDeps);
// Carry over context factory options from the old pipeline so that
// email, cron reschedule, and other wired-in options are preserved.
// The old pipeline's contextFactoryOptions were built up incrementally
// via setContextFactory calls during create(). We replay them here.
if (this.email) {
// db/getDb are already wired by createHookPipeline above (they live in
// pipelineFactoryOptions), so the merge only adds emailPipeline.
newPipeline.setContextFactory({ emailPipeline: this.email });
}
newPipeline.setContextFactory({
// Plugin schedules remain database-backed when no in-process scheduler
// exists; an external trigger is responsible for invoking due tasks.
cronReschedule: () => this.cronScheduler?.reschedule(),
});
// Update the email pipeline to use the new hook pipeline
if (this.email) {
this.email.setPipeline(newPipeline);
}
// Update the mutable ref so the cron closure dispatches through
// the new pipeline without needing to reconstruct the CronExecutor.
this.pipelineRef.current = newPipeline;
this._hooks = newPipeline;
}
/**
* Synchronize marketplace plugin runtime state with DB + storage.
*
* Ensures install/update/uninstall changes take effect immediately in the
* current worker: loads newly active plugins and removes uninstalled ones.
*/
async syncMarketplacePlugins(): Promise<void> {
if (!this.config.marketplace) return;
// In sandbox bypass mode (sandbox: false), the noop runner reports
// unavailable but we still want admin metadata for newly installed
// marketplace plugins to refresh in-process. Hooks/routes still won't
// execute (matches the cold-start bypass behavior), but Configure
// links and admin pages appear immediately.
if (this.runtimeDeps.sandboxBypassed) {
await this.syncMarketplacePluginsBypassed();
return;
}
await this.syncSandboxedSourcePlugins("marketplace");
}
/**
* Synchronize registry plugin runtime state with DB + storage.
*
* Mirrors {@link syncMarketplacePlugins} for plugins installed via the
* experimental decentralized plugin registry. Called after install,
* update, and uninstall handlers complete.
*/
async syncRegistryPlugins(): Promise<void> {
if (!this.config.experimental?.registry) return;
await this.syncSandboxedSourcePlugins("registry");
}
/**
* Internal: reconcile in-memory sandboxed-plugin state with the
* `_plugin_state` table for the given source tier. Shared
* implementation behind {@link syncMarketplacePlugins} and
* {@link syncRegistryPlugins}.
*
* Each source tier has its own key set in `${source}PluginKeys` so a
* sync for one tier doesn't invalidate the other.
*/
private async syncSandboxedSourcePlugins(source: "marketplace" | "registry"): Promise<void> {
if (!this.storage) return;
if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys;
try {
const stateRepo = new PluginStateRepository(this.db);
const states =
source === "marketplace"
? await stateRepo.getMarketplacePlugins()
: await stateRepo.getRegistryPlugins();
const desired = new Map<string, string>();
for (const state of states) {
this.pluginStates.set(state.pluginId, state.status);
if (state.status === "active") {
this.enabledPlugins.add(state.pluginId);
} else {
this.enabledPlugins.delete(state.pluginId);
}
if (state.status !== "active") continue;
// Marketplace plugins use `marketplaceVersion` when present;
// registry plugins always use `version`.
const desiredVersion =
source === "marketplace" ? (state.marketplaceVersion ?? state.version) : state.version;
desired.set(state.pluginId, desiredVersion);
}
// Remove uninstalled or no-longer-active plugins from memory.
const keysToRemove: string[] = [];
for (const key of keySet) {
const [pluginId] = key.split(":");
if (!pluginId) continue;
const desiredVersion = desired.get(pluginId);
if (desiredVersion && key === `${pluginId}:${desiredVersion}`) continue;
keysToRemove.push(key);
}
for (const key of keysToRemove) {
const [pluginId] = key.split(":");
if (!pluginId) continue;
const desiredVersion = desired.get(pluginId);
if (!desiredVersion) {
this.pluginStates.delete(pluginId);
this.enabledPlugins.delete(pluginId);
}
const existing = sandboxedPluginCache.get(key);
if (existing) {
try {
await existing.terminate();
} catch (error) {
console.warn(`EmDash: Failed to terminate sandboxed plugin ${key}:`, error);
}
}
sandboxedPluginCache.delete(key);
this.sandboxedPlugins.delete(key);
keySet.delete(key);
if (pluginId) {
sandboxedRouteMetaCache.delete(pluginId);
marketplaceManifestCache.delete(pluginId);
}
}
// Load newly active plugins.
for (const [pluginId, version] of desired) {
const key = `${pluginId}:${version}`;
if (sandboxedPluginCache.has(key)) {
keySet.add(key);
continue;
}
const bundle = await loadBundleFromR2(this.storage, pluginId, version, source);
if (!bundle) {
console.warn(`EmDash: ${source} plugin ${pluginId}@${version} not found in R2`);
continue;
}
const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode);
sandboxedPluginCache.set(key, loaded);
this.sandboxedPlugins.set(key, loaded);
keySet.add(key);
// Cache manifest admin config for getManifest()
marketplaceManifestCache.set(pluginId, {
id: bundle.manifest.id,
version: bundle.manifest.version,
admin: bundle.manifest.admin,
mcp: bundle.manifest.mcp,
storage: bundle.manifest.storage,
});
// Cache route metadata from manifest for auth decisions
if (bundle.manifest.routes.length > 0) {
const routeMetaMap = new Map<string, RouteMeta>();
for (const entry of bundle.manifest.routes) {
const normalized = normalizeManifestRoute(entry);
routeMetaMap.set(normalized.name, buildRouteMeta(normalized));
}
sandboxedRouteMetaCache.set(pluginId, routeMetaMap);
} else {
sandboxedRouteMetaCache.delete(pluginId);
}
}
} catch (error) {
console.error(`EmDash: Failed to sync ${source} plugins:`, error);
}
}
/**
* Remove a plugin from the in-memory pipeline lists by ID.
* Mutates allPipelinePlugins and configuredPlugins in place.
*/
private removePluginFromLists(pluginId: string): void {
const allIdx = this.allPipelinePlugins.findIndex((p) => p.id === pluginId);
if (allIdx !== -1) this.allPipelinePlugins.splice(allIdx, 1);
const configIdx = this.configuredPlugins.findIndex((p) => p.id === pluginId);