@nuasite/cms 0.23.0 → 0.23.1

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.
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.23.0",
17
+ "version": "0.23.1",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Vite SSR module cache invalidation + content-sync coordination.
3
+ *
4
+ * Astro's content layer chain (chokidar → glob loader → syncData → data store
5
+ * → fs.watch → invalidateModule) is racy and unreliable under several conditions:
6
+ *
7
+ * - Native fs.watch on Linux dies after the first atomic rename of the watched
8
+ * file (Astro writes data-store.json via writeFile-tmp + rename).
9
+ * - Vite's bundled chokidar 3.6.0 misses the same atomic-write events.
10
+ * - `invalidateModule(astro:data-layer-content)` alone does not propagate up
11
+ * the import graph, so route modules that already cached `getCollection`
12
+ * references keep returning stale data.
13
+ *
14
+ * This module exposes two things:
15
+ *
16
+ * - `invalidateContentCache(server)` — walks the SSR module graph from
17
+ * `astro:data-layer-content` upward and invalidates every transitive
18
+ * importer, then broadcasts `full-reload` to the client.
19
+ * - `notifyContentStoreUpdated` / `awaitNextContentStoreUpdate` — a shared
20
+ * rendezvous between the fs.watch plugin (which observes data-store.json
21
+ * writes) and the CMS API middleware (which needs to hold the HTTP
22
+ * response until the store is fresh). Keeps invalidation on a single path.
23
+ */
24
+
25
+ interface SsrModuleNode {
26
+ id: string | null
27
+ importers: Set<SsrModuleNode>
28
+ }
29
+
30
+ interface SsrModuleGraph {
31
+ getModuleById(id: string): SsrModuleNode | undefined
32
+ invalidateModule(
33
+ mod: SsrModuleNode,
34
+ seen?: Set<SsrModuleNode>,
35
+ timestamp?: number,
36
+ isHmr?: boolean,
37
+ ): void
38
+ }
39
+
40
+ interface SsrEnvironment {
41
+ moduleGraph: SsrModuleGraph
42
+ hot: { send: (event: string, data?: unknown) => void }
43
+ }
44
+
45
+ interface ClientEnvironment {
46
+ hot: { send: (payload: { type: string; path: string }) => void }
47
+ }
48
+
49
+ export interface ViteServerLike {
50
+ environments: { ssr: SsrEnvironment; client: ClientEnvironment }
51
+ }
52
+
53
+ // Astro exposes the content data store as a virtual module whose resolved id
54
+ // is `\0astro:data-layer-content` (see astro/dist/content/consts.js). Earlier
55
+ // versions of this file used `\0astro:data-store`, which does not exist and
56
+ // silently reduced `invalidateContentCache` to a no-op full-reload broadcast.
57
+ const DATA_STORE_VIRTUAL_ID = '\0astro:data-layer-content'
58
+
59
+ /**
60
+ * Invalidate the SSR `astro:data-layer-content` virtual module and every
61
+ * module that (transitively) imports it. After this returns, the next request
62
+ * that imports any of these modules will re-execute and read fresh content.
63
+ *
64
+ * Also broadcasts `full-reload` so any connected browser refreshes.
65
+ */
66
+ export function invalidateContentCache(server: ViteServerLike): void {
67
+ const ssr = server.environments.ssr
68
+ const dataStoreMod = ssr.moduleGraph.getModuleById(DATA_STORE_VIRTUAL_ID)
69
+ if (dataStoreMod) {
70
+ const seen = new Set<SsrModuleNode>()
71
+ const ts = Date.now()
72
+ const walk = (mod: SsrModuleNode) => {
73
+ if (seen.has(mod)) return
74
+ seen.add(mod)
75
+ ssr.moduleGraph.invalidateModule(mod, seen, ts, true)
76
+ for (const importer of mod.importers) {
77
+ walk(importer)
78
+ }
79
+ }
80
+ walk(dataStoreMod)
81
+ }
82
+ ssr.hot.send('astro:content-changed', {})
83
+ server.environments.client.hot.send({ type: 'full-reload', path: '*' })
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Content-sync rendezvous
88
+ // ---------------------------------------------------------------------------
89
+ //
90
+ // The CMS API middleware writes a content file and then needs to hold the HTTP
91
+ // response until Astro has actually re-synced the data store — otherwise the
92
+ // browser reloads into a stale render. The fs.watch plugin is the component
93
+ // that observes the data-store.json write, so it is also the component that
94
+ // resolves these waiters.
95
+
96
+ type StoreUpdateResolver = () => void
97
+ const pendingStoreUpdateWaiters = new Set<StoreUpdateResolver>()
98
+
99
+ /**
100
+ * Called by the data-store fs.watch plugin after it has invalidated the SSR
101
+ * module cache in response to a data-store.json write. Wakes every middleware
102
+ * caller currently parked in `awaitNextContentStoreUpdate`.
103
+ */
104
+ export function notifyContentStoreUpdated(): void {
105
+ if (pendingStoreUpdateWaiters.size === 0) return
106
+ const resolvers = Array.from(pendingStoreUpdateWaiters)
107
+ pendingStoreUpdateWaiters.clear()
108
+ for (const resolve of resolvers) resolve()
109
+ }
110
+
111
+ /**
112
+ * Park until the next data-store.json write has been fully processed (store
113
+ * reloaded on disk, SSR module graph invalidated). Resolves with `true` on
114
+ * success or `false` if the timeout elapses first — callers should treat
115
+ * timeout as "best-effort, proceed anyway".
116
+ *
117
+ * The timeout fallback exists because some edits legitimately do not change
118
+ * the data store (e.g. whitespace-only edits are skipped by Astro's atomic
119
+ * write comparator), in which case no fs.watch event will ever fire.
120
+ */
121
+ export function awaitNextContentStoreUpdate(timeoutMs: number): Promise<boolean> {
122
+ return new Promise((resolve) => {
123
+ const resolver = () => {
124
+ clearTimeout(timer)
125
+ pendingStoreUpdateWaiters.delete(resolver)
126
+ resolve(true)
127
+ }
128
+ const timer = setTimeout(() => {
129
+ pendingStoreUpdateWaiters.delete(resolver)
130
+ resolve(false)
131
+ }, timeoutMs)
132
+ pendingStoreUpdateWaiters.add(resolver)
133
+ })
134
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import path from 'node:path'
4
4
  import { getProjectRoot } from './config'
5
+ import { awaitNextContentStoreUpdate } from './content-invalidator'
5
6
  import { handleCmsApiRoute } from './handlers/api-routes'
6
7
  import { buildMapPattern, detectArrayPattern, extractArrayElementProps, parseInlineArrayName } from './handlers/array-ops'
7
8
  import {
@@ -43,6 +44,7 @@ interface ViteDevServerLike {
43
44
  watcher?: {
44
45
  on: (event: string, listener: (...args: any[]) => void) => any
45
46
  removeListener: (event: string, listener: (...args: any[]) => void) => any
47
+ emit: (event: string, ...args: any[]) => boolean
46
48
  }
47
49
  }
48
50
 
@@ -100,6 +102,45 @@ export function createDevMiddleware(
100
102
 
101
103
  // CMS API endpoints (local dev server backend)
102
104
  if (options.enableCmsApi) {
105
+ const projectRoot = getProjectRoot()
106
+
107
+ /**
108
+ * Hold the HTTP response for a `markdown/update` (or equivalent) call
109
+ * until Astro's content layer has actually re-synced the edited file.
110
+ *
111
+ * The race we're fixing: handleUpdateMarkdown writes the file and
112
+ * returns immediately, the editor then triggers a full-reload, and
113
+ * the next page render reads a still-cached `astro:data-layer-content`
114
+ * virtual module — so the user sees their edit disappear until Astro's
115
+ * async chain (glob loader → syncData → 500 ms save debounce → atomic
116
+ * write → fs.watch → invalidateModule) finally catches up.
117
+ *
118
+ * The fix, end to end:
119
+ *
120
+ * 1. `server.watcher.emit('change', fullPath)` kicks Astro's glob
121
+ * loader directly. It is registered on this exact watcher (see
122
+ * astro/dist/core/dev/dev.js — `viteServer.watcher` is handed to
123
+ * `globalContentLayer.init`), so synthetic change events fire its
124
+ * `onChange` handler and trigger `syncData`. This also works
125
+ * around Vite's bundled chokidar missing some edits.
126
+ * 2. `awaitNextContentStoreUpdate` parks until the shared data-store
127
+ * watcher (in `vite-plugin.ts`) observes the resulting atomic
128
+ * write and finishes invalidating the SSR module graph.
129
+ * 3. Only then do we return — so the subsequent full-reload lands
130
+ * on a page that will re-execute with fresh content.
131
+ *
132
+ * The timeout fallback covers edits that legitimately do not rewrite
133
+ * the data store (Astro's MutableDataStore skips identical writes).
134
+ * In that case no fs.watch event will ever fire, and 3 s is plenty of
135
+ * budget before we give up and let the response through anyway.
136
+ */
137
+ const notifyContentChanged = async (filePath: string): Promise<void> => {
138
+ const fullPath = path.resolve(projectRoot, filePath)
139
+ const waiter = awaitNextContentStoreUpdate(3000)
140
+ server.watcher?.emit('change', fullPath)
141
+ await waiter
142
+ }
143
+
103
144
  server.middlewares.use((req, res, next) => {
104
145
  const url = req.url || ''
105
146
  if (!url.startsWith('/_nua/cms/')) {
@@ -111,7 +152,7 @@ export function createDevMiddleware(
111
152
 
112
153
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
113
154
 
114
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
155
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter, notifyContentChanged)
115
156
  .catch((error) => {
116
157
  console.error('[astro-cms] API error:', error)
117
158
  sendError(res, 'Internal server error', 500)
@@ -109,9 +109,8 @@ const CmsUI = () => {
109
109
  // Re-fetch manifest on View Transitions navigation (astro:after-swap)
110
110
  useEffect(() => {
111
111
  const onNavigation = () => {
112
- fetchManifest().then((manifest) => {
113
- signals.setManifest(manifest)
114
- }).catch(() => {})
112
+ postToParent({ type: 'cms-page-navigated', page: { pathname: window.location.pathname } })
113
+ fetchManifest().then((manifest) => signals.setManifest(manifest)).catch(() => {})
115
114
  }
116
115
  document.addEventListener('astro:after-swap', onNavigation)
117
116
  return () => document.removeEventListener('astro:after-swap', onNavigation)
@@ -21,6 +21,15 @@ export interface RouteContext {
21
21
  manifestWriter: ManifestWriter
22
22
  contentDir: string
23
23
  mediaAdapter?: MediaStorageAdapter
24
+ /**
25
+ * Triggered after a content file (markdown / data collection) is written so
26
+ * the dev middleware can synchronously refresh Astro's content layer and
27
+ * invalidate Vite's SSR module cache before responding to the client.
28
+ *
29
+ * Awaiting this is important: returning success before the cache is fresh
30
+ * causes the editor to reload the page into a stale render.
31
+ */
32
+ notifyContentChanged?: (filePath: string) => Promise<void>
24
33
  }
25
34
 
26
35
  type RouteHandler = (ctx: RouteContext) => Promise<void>
@@ -104,9 +113,13 @@ const routeMap = new Map<string, RouteHandler>([
104
113
  }
105
114
  sendJson(res, result)
106
115
  }),
107
- custom('POST', 'markdown/update', async ({ req, res, manifestWriter }) => {
116
+ custom('POST', 'markdown/update', async ({ req, res, manifestWriter, notifyContentChanged }) => {
108
117
  const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
109
- sendJson(res, await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions()))
118
+ const result = await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions())
119
+ if (result.success && notifyContentChanged) {
120
+ await notifyContentChanged(body.filePath)
121
+ }
122
+ sendJson(res, result)
110
123
  }),
111
124
  post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
112
125
  postWithStatus('markdown/create', (body: Parameters<typeof handleCreateMarkdown>[0]) => handleCreateMarkdown(body)),
@@ -225,8 +238,9 @@ export async function handleCmsApiRoute(
225
238
  manifestWriter: ManifestWriter,
226
239
  contentDir: string,
227
240
  mediaAdapter?: MediaStorageAdapter,
241
+ notifyContentChanged?: (filePath: string) => Promise<void>,
228
242
  ): Promise<void> {
229
- const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter }
243
+ const ctx: RouteContext = { req, res, route, manifestWriter, contentDir, mediaAdapter, notifyContentChanged }
230
244
 
231
245
  // Exact match lookup
232
246
  const handler = routeMap.get(`${req.method}:${route}`)
@@ -1,6 +1,7 @@
1
1
  import { watch } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Plugin } from 'vite'
4
+ import { invalidateContentCache, notifyContentStoreUpdated, type ViteServerLike } from './content-invalidator'
4
5
  import { expectedDeletions } from './dev-middleware'
5
6
  import type { ManifestWriter } from './manifest-writer'
6
7
  import { markFileDirty } from './source-finder'
@@ -85,6 +86,11 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
85
86
  // Without this, content collection edits update the data store on disk but the
86
87
  // browser never receives a full-reload because Vite's watcher never fires "change"
87
88
  // for that file. We use native fs.watch as a reliable fallback.
89
+ //
90
+ // Caveat: native fs.watch on Linux tracks the inode, not the path. Astro writes
91
+ // data-store.json via atomic rename (writeFile-tmp + rename), which replaces the
92
+ // inode and silently kills the existing watcher. We re-attach on every event to
93
+ // keep tracking the live file across atomic writes.
88
94
  const dataStoreWatchPlugin: Plugin = {
89
95
  name: 'cms-data-store-watch',
90
96
  configureServer(server) {
@@ -93,25 +99,32 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
93
99
  const dataStorePath = join(root, '.astro', 'data-store.json')
94
100
  let fsWatcher: ReturnType<typeof watch> | undefined
95
101
  let debounce: ReturnType<typeof setTimeout> | undefined
102
+ let closed = false
96
103
 
97
104
  const invalidate = () => {
98
- // Replicate Astro's invalidateDataStore which never fires because
99
- // Vite's bundled chokidar 3.6.0 misses atomic-write changes.
100
- const ssr = server.environments.ssr
101
- const mod = ssr.moduleGraph.getModuleById('\0astro:data-store')
102
- if (mod) {
103
- ssr.moduleGraph.invalidateModule(mod, undefined, Date.now(), true)
104
- }
105
- ssr.hot.send('astro:content-changed', {})
106
- server.environments.client.hot.send({ type: 'full-reload', path: '*' })
105
+ invalidateContentCache(server as unknown as ViteServerLike)
106
+ // Wake any CMS API middleware call that is currently blocked
107
+ // waiting for the data store to reflect a just-written file.
108
+ // This keeps the invalidation on a single path (here) and lets
109
+ // the middleware respond only after the SSR module graph is fresh.
110
+ notifyContentStoreUpdated()
111
+ }
112
+
113
+ const onEvent = () => {
114
+ clearTimeout(debounce)
115
+ debounce = setTimeout(invalidate, 80)
116
+ // Re-attach: native fs.watch dies after the inode is replaced by an
117
+ // atomic rename. Close current and restart so subsequent writes are
118
+ // observed.
119
+ fsWatcher?.close()
120
+ fsWatcher = undefined
121
+ if (!closed) startWatching()
107
122
  }
108
123
 
109
124
  const startWatching = () => {
125
+ if (closed) return
110
126
  try {
111
- fsWatcher = watch(dataStorePath, () => {
112
- clearTimeout(debounce)
113
- debounce = setTimeout(invalidate, 80)
114
- })
127
+ fsWatcher = watch(dataStorePath, onEvent)
115
128
  } catch {
116
129
  // File doesn't exist yet — retry when it appears
117
130
  setTimeout(startWatching, 2000)
@@ -123,6 +136,7 @@ export function createVitePlugin(context: VitePluginContext): Plugin[] {
123
136
 
124
137
  const origClose = server.close.bind(server)
125
138
  server.close = async () => {
139
+ closed = true
126
140
  fsWatcher?.close()
127
141
  clearTimeout(debounce)
128
142
  return origClose()