@nuasite/cms 0.22.3 → 0.22.4
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/dist/editor.js +1 -1
- package/package.json +1 -1
- package/src/content-invalidator.ts +134 -0
- package/src/dev-middleware.ts +42 -1
- package/src/handlers/api-routes.ts +17 -3
- package/src/vite-plugin.ts +27 -13
package/dist/editor.js
CHANGED
|
@@ -381,7 +381,7 @@ function CS(t, e) {
|
|
|
381
381
|
function ES(t, e) {
|
|
382
382
|
return typeof e == "function" ? e(t) : e;
|
|
383
383
|
}
|
|
384
|
-
const J_ = "0.22.
|
|
384
|
+
const J_ = "0.22.4", j_ = J_, ct = {
|
|
385
385
|
/** Highlight overlay for hovered elements */
|
|
386
386
|
HIGHLIGHT: 2147483644,
|
|
387
387
|
/** Hover outline for elements/components */
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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)
|
|
@@ -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
|
-
|
|
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}`)
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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()
|