directus-live-preview-bridge 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritsa Tech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,205 @@
1
+ # Directus Live Preview bridge
2
+
3
+ `directus-live-preview-bridge` sends unsaved item edits from the Directus Data
4
+ Studio to the page open in Live Preview. It does not save content or call the
5
+ Directus API.
6
+
7
+ The package is one Directus bundle with two parts. A hook adds a deferred
8
+ same-origin script tag to the Data Studio, and an endpoint serves the script at
9
+ `/live-preview-bridge/script.js`. Keeping the executable code in an external
10
+ same-origin response works with the Data Studio's Content Security Policy.
11
+
12
+ ## Requirements
13
+
14
+ - Directus 12
15
+ - Node.js 22 or later
16
+ - A frontend that handles the bridge protocol described below
17
+
18
+ The bundle uses Directus' server-side `embed` hook. Directus does not expose
19
+ that hook to sandboxed extensions, so this package runs as an unsandboxed API
20
+ extension. Review the source before installing it in a production project.
21
+
22
+ ## Install
23
+
24
+ Install the package alongside Directus:
25
+
26
+ ```sh
27
+ pnpm add directus-live-preview-bridge
28
+ ```
29
+
30
+ For a custom Directus Docker image:
31
+
32
+ ```dockerfile
33
+ FROM directus/directus:12.3.1
34
+
35
+ USER root
36
+ RUN corepack enable
37
+ USER node
38
+
39
+ RUN pnpm add directus-live-preview-bridge
40
+ ```
41
+
42
+ You can also build this repository and copy `package.json` plus `dist/` into
43
+ `extensions/directus-live-preview-bridge/`. Restart Directus after installing
44
+ the bundle.
45
+
46
+ ### Marketplace installs
47
+
48
+ The npm package contains the metadata required by the Directus Marketplace.
49
+ Because the bundle is unsandboxed, Directus hides it under the default
50
+ `MARKETPLACE_TRUST=sandbox` setting. Self-hosted and Enterprise Cloud projects
51
+ must set `MARKETPLACE_TRUST=all` before installing it from the Marketplace.
52
+
53
+ ## Connect a preview frontend
54
+
55
+ The bridge posts this payload to the current Live Preview iframe:
56
+
57
+ ```ts
58
+ type LivePreviewEditsMessage = {
59
+ type: 'directus-live-preview-bridge:edits';
60
+ collection: string;
61
+ primaryKey: unknown;
62
+ edits: Record<string, unknown>;
63
+ initialValues: Record<string, unknown>;
64
+ };
65
+ ```
66
+
67
+ The target origin comes from the iframe URL. The bridge never uses `*` as the
68
+ `postMessage` target.
69
+
70
+ Register the listener before telling the Data Studio that the preview is ready:
71
+
72
+ ```ts
73
+ const EDITS_MESSAGE_TYPE = 'directus-live-preview-bridge:edits';
74
+ const READY_MESSAGE_TYPE = 'directus-live-preview-bridge:ready';
75
+
76
+ export function connectDirectusLivePreview(
77
+ directusUrl: string,
78
+ allowedCollections: ReadonlySet<string>,
79
+ applyEdits: (message: {
80
+ collection: string;
81
+ primaryKey: unknown;
82
+ edits: Record<string, unknown>;
83
+ initialValues?: Record<string, unknown>;
84
+ }) => void,
85
+ ) {
86
+ const directusOrigin = new URL(directusUrl).origin;
87
+
88
+ function receiveEdits(event: MessageEvent) {
89
+ if (event.origin !== directusOrigin || event.source !== window.parent)
90
+ return;
91
+
92
+ const message = event.data;
93
+
94
+ if (
95
+ !message ||
96
+ message.type !== EDITS_MESSAGE_TYPE ||
97
+ typeof message.collection !== 'string' ||
98
+ !allowedCollections.has(message.collection) ||
99
+ typeof message.edits !== 'object' ||
100
+ message.edits === null ||
101
+ Array.isArray(message.edits) ||
102
+ (message.initialValues !== undefined &&
103
+ (typeof message.initialValues !== 'object' ||
104
+ message.initialValues === null ||
105
+ Array.isArray(message.initialValues)))
106
+ ) {
107
+ return;
108
+ }
109
+
110
+ applyEdits(message);
111
+ }
112
+
113
+ window.addEventListener('message', receiveEdits);
114
+ window.parent.postMessage({ type: READY_MESSAGE_TYPE }, directusOrigin);
115
+
116
+ return () => window.removeEventListener('message', receiveEdits);
117
+ }
118
+ ```
119
+
120
+ Validate the collection and item in your application before merging `edits` into
121
+ rendered data. Treat every cross-window message as untrusted input, even when
122
+ its origin is expected.
123
+
124
+ Each message is a complete snapshot of the current `edits`, not an incremental
125
+ patch. Render allowed fields from `initialValues`, then apply the current edits.
126
+ Do not merge into the previous rendered result: a field reset removes its edit
127
+ key. After a save, `initialValues` contains the new saved baseline and `edits`
128
+ can be empty. Older bridge versions omit `initialValues`; use server-fetched
129
+ content as the baseline for those messages. Both objects can contain fields
130
+ available to the editor, so use a trusted preview frontend.
131
+
132
+ The ready message matters. The Data Studio may send its first update before the
133
+ preview application has mounted. On receipt, the bridge replays the latest
134
+ state.
135
+
136
+ ### Next.js App Router example
137
+
138
+ The [Next.js guide](docs/nextjs.md) shows a complete Directus 12 and Next.js App
139
+ Router setup. It covers Draft Mode, a protected preview route, server-side
140
+ Directus fetching, a React client component that applies unsaved edits, and an
141
+ optional route for leaving Draft Mode.
142
+
143
+ ## Configuration
144
+
145
+ The defaults work when Directus is served from the root of its origin.
146
+
147
+ | Environment variable | Default | Purpose |
148
+ | -------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
149
+ | `LIVE_PREVIEW_BRIDGE_SCRIPT_PATH` | `/live-preview-bridge/script.js` | Root-relative endpoint path inserted into the Data Studio. Set this when a reverse proxy serves Directus below a path such as `/cms`. |
150
+ | `LIVE_PREVIEW_BRIDGE_POLL_INTERVAL_MS` | `120` | How often the bridge reads staged form state. Accepted values are integers from `50` through `5000`. |
151
+
152
+ For a Directus instance served at `https://example.com/cms`, use:
153
+
154
+ ```env
155
+ LIVE_PREVIEW_BRIDGE_SCRIPT_PATH=/cms/live-preview-bridge/script.js
156
+ ```
157
+
158
+ The script path only accepts a same-origin, root-relative URL. Invalid values
159
+ fall back to the default and produce a warning in the Directus log.
160
+
161
+ ## How edit capture works
162
+
163
+ Directus 12 does not provide a public client API for reading the staged item
164
+ form. The bridge finds the current form through Vue's mounted VNode tree and
165
+ polls its props. This tree is present in production builds, including those that
166
+ omit per-element Vue devtools metadata. The bridge reads normalized form values
167
+ for native inputs and custom controls such as dropdowns. It skips loading or
168
+ unmounted forms and does not keep a separate cache of native input values.
169
+
170
+ This dependency on Data Studio internals is why the package targets Directus 12
171
+ explicitly. Run the test suite against a Directus upgrade before rolling it out.
172
+
173
+ The bridge serializes edits through JSON before posting them. Circular values
174
+ are skipped, and repeated payloads are not sent again unless the iframe reports
175
+ that it is ready.
176
+
177
+ ## Development
178
+
179
+ ```sh
180
+ npm install
181
+ npm run check
182
+ ```
183
+
184
+ `npm run check` type-checks the source, runs the tests, checks formatting,
185
+ builds the bundle, and validates the Directus manifest. Use `npm run dev` for a
186
+ watch build or `npm run link` to link the package into a local Directus
187
+ extensions directory.
188
+
189
+ The full development dependency audit currently reports an upstream `unhead`
190
+ advisory through the Directus SDK. It is not included in the compiled extension
191
+ or installed by package consumers. The
192
+ [security policy](https://github.com/ritsa-tech/directus-live-preview-bridge/blob/main/SECURITY.md#development-dependency-audit)
193
+ records the dependency path and verification commands.
194
+
195
+ Releases use Conventional Commits and Release Please. The publish job uses npm
196
+ trusted publishing. npm requires the package to exist before that trust can be
197
+ configured, so maintainers must publish the initial version manually. After
198
+ that, configure `ritsa-tech/directus-live-preview-bridge` and
199
+ `.github/workflows/release-please.yml` as the trusted publisher. The publish job
200
+ uses an `npm` GitHub environment. Add required reviewers to that environment if
201
+ releases need manual approval.
202
+
203
+ ## License
204
+
205
+ [MIT](LICENSE)
package/dist/api.js ADDED
@@ -0,0 +1 @@
1
+ const e=120,n="/live-preview-bridge/script.js",t="https://directus.invalid";function r(e={}){const n=window;if(n.__directusLivePreviewBridgeInstalled)return;n.__directusLivePreviewBridgeInstalled=!0;const t=Number.isInteger(e.pollIntervalMs)&&Number(e.pollIntervalMs)>=50&&Number(e.pollIntervalMs)<=5e3?Number(e.pollIntervalMs):120;let r=null,i=null,l=null;function o(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function s(e){if("string"==typeof e.collection&&e.collection)return e.collection;if(!Array.isArray(e.fields))return null;const n=e.fields[0];return o(n)&&"string"==typeof n.collection&&n.collection&&e.fields.every(e=>o(e)&&e.collection===n.collection)?n.collection:null}function c(e){const n=Array.from(document.querySelectorAll("[data-v-app]"),e=>e._vnode),t=new Set;for(;n.length;){const r=n.pop();if(!o(r)||t.has(r))continue;t.add(r);const i=r,l=i.component;if(l){if(l.isUnmounted||l.isDeactivated)continue;const t=l.props;if(l.subTree?.el===e&&t&&null!==s(t)&&"modelValue"in t&&"initialValues"in t)return l;n.push(l.subTree)}else i.suspense?n.push(i.suspense.activeBranch):Array.isArray(i.children)&&n.push(...i.children)}return null}function u(){const e=document.querySelector(".live-preview iframe#frame");return e instanceof HTMLIFrameElement&&e.contentWindow?e:null}function a(e){try{const n=new URL(e.src);return"http:"!==n.protocol&&"https:"!==n.protocol?null:n.origin}catch{return null}}function p(){const e=u(),n=e?function(){for(const e of document.querySelectorAll(".v-form")){const n=!l||l.isUnmounted||l.isDeactivated||l.subTree?.el!==e?c(e):l,t=n?.props;if(!t)continue;const r=s(t);if(r)return l=n,t.loading?null:{collection:r,edits:o(t.modelValue)?t.modelValue:{},initialValues:o(t.initialValues)?t.initialValues:{},primaryKey:t.primaryKey??null}}return l=null,null}():null;if(!e||!n)return l=null,r=null,void(i=null);const t=a(e);if(!t)return;const p=function(e){try{return JSON.stringify(e)}catch{return null}}({type:"directus-live-preview-bridge:edits",...n});if(null===p)return;const d=`${e.src}\0${p}`;e===r&&d===i||(e.contentWindow?.postMessage(JSON.parse(p),t),r=e,i=d)}window.addEventListener("message",e=>{if("directus-live-preview-bridge:ready"!==e.data?.type)return;const n=u();if(!n||e.source!==n.contentWindow)return;const t=a(n);t&&e.origin===t&&(i=null,p())}),window.setInterval(p,t)}const i=[{name:"live-preview-bridge-hook",config:({embed:e},{env:r,logger:i})=>{const l=r.LIVE_PREVIEW_BRIDGE_SCRIPT_PATH,o=function(e){if(null==e||""===e)return n;if("string"!=typeof e||!e.startsWith("/"))return n;try{const r=new URL(e,t);return r.origin!==t?n:`${r.pathname}${r.search}${r.hash}`}catch{return n}}(l);var s;l&&l!==n&&o===n&&i.warn("LIVE_PREVIEW_BRIDGE_SCRIPT_PATH must be a same-origin, root-relative URL path. Using the default path."),e("body",`<script defer src="${s=o,s.replaceAll("&","&amp;").replaceAll('"',"&quot;").replaceAll("'","&#39;").replaceAll("<","&lt;").replaceAll(">","&gt;")}"><\/script>`)}}],l=[{name:"live-preview-bridge-endpoint",config:{id:"live-preview-bridge",handler(n,{env:t,logger:i}){const l=t.LIVE_PREVIEW_BRIDGE_POLL_INTERVAL_MS,o=function(n){if(null==n||""===n)return e;const t=Number(n);return!Number.isInteger(t)||t<50||t>5e3?e:t}(l);void 0!==l&&o===e&&Number(l)!==e&&i.warn("LIVE_PREVIEW_BRIDGE_POLL_INTERVAL_MS must be an integer from 50 through 5000. Using 120 ms.");const s=function(e){const n=JSON.stringify({pollIntervalMs:e});return`;(${r.toString()})(${n});`}(o);n.get("/script.js",(e,n)=>{n.set("Cache-Control","no-store").set("X-Content-Type-Options","nosniff").type("application/javascript").send(s)})}}}],o=[];export{l as endpoints,i as hooks,o as operations};
package/dist/app.js ADDED
@@ -0,0 +1 @@
1
+ const o=[],t=[],c=[],e=[],n=[],p=[],r=[];export{t as displays,o as interfaces,c as layouts,e as modules,r as operations,n as panels,p as themes};
package/docs/nextjs.md ADDED
@@ -0,0 +1,402 @@
1
+ # Use the bridge with Next.js Draft Mode
2
+
3
+ This example uses the Next.js App Router and its async request APIs. It matches
4
+ Next.js 15 and 16, where `draftMode()` returns a promise. The example collection
5
+ is `articles`, with `id`, `status`, `title`, and `body` fields.
6
+
7
+ Draft Mode and this bridge solve different parts of previewing:
8
+
9
+ | Part | Responsibility |
10
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
11
+ | Directus item status | Determines whether a saved item is a draft or published. |
12
+ | Next.js Draft Mode | Sets a browser cookie so Next.js renders the request using draft-aware data fetching instead of a cached published result. |
13
+ | Live Preview bridge | Sends the values currently typed in the Directus form, including changes that have not been saved. |
14
+
15
+ The request flow is straightforward. Directus opens a protected Next.js route
16
+ inside its Live Preview iframe. That route validates the request, enables Draft
17
+ Mode, and redirects to the article page. The Server Component fetches the saved
18
+ draft as its initial value. After hydration, the client component receives
19
+ unsaved form edits from the bridge and merges the supported fields into React
20
+ state.
21
+
22
+ Next.js documents the Draft Mode cookie and async API in its
23
+ [Draft Mode guide](https://nextjs.org/docs/app/guides/draft-mode). Directus
24
+ documents the collection Preview URL in its
25
+ [Live Preview guide](https://directus.io/docs/tutorials/getting-started/set-up-live-preview-with-next-js).
26
+
27
+ ## 1. Configure environment variables
28
+
29
+ Add these values to the Next.js project:
30
+
31
+ ```env
32
+ # Available to the client. This is an origin, not an API token.
33
+ NEXT_PUBLIC_DIRECTUS_URL=https://directus.example.com
34
+
35
+ # Server-only token with read access to the fields used by the preview.
36
+ DIRECTUS_SERVER_TOKEN=replace-with-a-limited-read-token
37
+
38
+ # Shared by the Next.js preview route and the Directus Preview URL.
39
+ DIRECTUS_PREVIEW_SECRET=replace-with-a-long-random-value
40
+ ```
41
+
42
+ Keep `DIRECTUS_SERVER_TOKEN` and `DIRECTUS_PREVIEW_SECRET` out of variables with
43
+ the `NEXT_PUBLIC_` prefix. The browser does not need either secret. Give the
44
+ Directus token read access only to the collections and fields the preview uses.
45
+
46
+ ## 2. Fetch saved Directus data on the server
47
+
48
+ Create `lib/directus.ts`. This helper fetches the saved draft while Draft Mode
49
+ is enabled. Normal visitors only receive items whose status is `published`.
50
+
51
+ ```ts
52
+ export type Article = {
53
+ id: string;
54
+ status: 'draft' | 'published' | 'archived';
55
+ title: string;
56
+ body: string;
57
+ };
58
+
59
+ type DirectusListResponse<T> = {
60
+ data: T[];
61
+ };
62
+
63
+ const directusUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL;
64
+ const directusToken = process.env.DIRECTUS_SERVER_TOKEN;
65
+
66
+ if (!directusUrl || !directusToken) {
67
+ throw new Error(
68
+ 'NEXT_PUBLIC_DIRECTUS_URL and DIRECTUS_SERVER_TOKEN are required.',
69
+ );
70
+ }
71
+
72
+ export async function getArticle(
73
+ id: string,
74
+ includeDrafts: boolean,
75
+ ): Promise<Article | null> {
76
+ const baseUrl = directusUrl.endsWith('/') ? directusUrl : `${directusUrl}/`;
77
+ const url = new URL('items/articles', baseUrl);
78
+ const filter = {
79
+ id: { _eq: id },
80
+ ...(includeDrafts ? {} : { status: { _eq: 'published' } }),
81
+ };
82
+
83
+ url.searchParams.set('filter', JSON.stringify(filter));
84
+ url.searchParams.set('fields', 'id,status,title,body');
85
+ url.searchParams.set('limit', '1');
86
+
87
+ const response = await fetch(url, {
88
+ headers: {
89
+ Authorization: `Bearer ${directusToken}`,
90
+ },
91
+ ...(includeDrafts
92
+ ? { cache: 'no-store' as const }
93
+ : { next: { revalidate: 60 } }),
94
+ });
95
+
96
+ if (!response.ok) {
97
+ throw new Error(`Directus returned ${response.status}.`);
98
+ }
99
+
100
+ const result = (await response.json()) as DirectusListResponse<Article>;
101
+ return result.data[0] ?? null;
102
+ }
103
+ ```
104
+
105
+ The `no-store` branch is deliberate. Draft requests should not share a cached
106
+ response with another request. Change the published revalidation interval to
107
+ match the site's normal caching policy. Adjust the status names and fields if
108
+ the Directus collection uses different values. Keep any Directus reverse-proxy
109
+ path in `NEXT_PUBLIC_DIRECTUS_URL`; the URL construction above preserves it.
110
+
111
+ ## 3. Enable Draft Mode through a protected route
112
+
113
+ Create `app/api/draft/route.ts`:
114
+
115
+ ```ts
116
+ import { draftMode } from 'next/headers';
117
+ import { redirect } from 'next/navigation';
118
+
119
+ import { getArticle } from '@/lib/directus';
120
+
121
+ const VALID_ID = /^[A-Za-z0-9_-]+$/;
122
+
123
+ export async function GET(request: Request) {
124
+ const { searchParams } = new URL(request.url);
125
+ const secret = searchParams.get('secret');
126
+ const id = searchParams.get('id');
127
+
128
+ if (
129
+ !process.env.DIRECTUS_PREVIEW_SECRET ||
130
+ secret !== process.env.DIRECTUS_PREVIEW_SECRET
131
+ ) {
132
+ return new Response('Invalid preview secret.', { status: 401 });
133
+ }
134
+
135
+ if (!id || !VALID_ID.test(id)) {
136
+ return new Response('Invalid article ID.', { status: 400 });
137
+ }
138
+
139
+ const article = await getArticle(id, true);
140
+
141
+ if (!article) {
142
+ return new Response('Article not found.', { status: 404 });
143
+ }
144
+
145
+ const draft = await draftMode();
146
+ draft.enable();
147
+
148
+ redirect(`/articles/${encodeURIComponent(article.id)}`);
149
+ }
150
+ ```
151
+
152
+ The route checks that the item exists before setting the Draft Mode cookie. It
153
+ also constructs the redirect from the verified item instead of redirecting to an
154
+ arbitrary query parameter. That avoids turning the route into an open redirect.
155
+
156
+ The cookie is named `__prerender_bypass`. Next.js generates a new value for each
157
+ build, so a deployment invalidates older Draft Mode cookies. Draft Mode does not
158
+ grant Directus access by itself. The server-only Directus token still controls
159
+ which saved records the page can fetch.
160
+
161
+ ## 4. Configure the Directus Preview URL
162
+
163
+ In Directus, open **Settings > Data Model > Articles** and set the Preview URL
164
+ to this pattern:
165
+
166
+ ```text
167
+ https://frontend.example.com/api/draft?secret=YOUR_SECRET&id=ID
168
+ ```
169
+
170
+ Use the Directus field picker to insert the `ID` field after `id=`. Replace
171
+ `YOUR_SECRET` with the same value as `DIRECTUS_PREVIEW_SECRET`. Use HTTPS
172
+ outside local development because the secret is part of the URL.
173
+
174
+ When an editor opens Live Preview, Directus loads this URL in its iframe. The
175
+ route sets the cookie and redirects the iframe to `/articles/<id>`.
176
+
177
+ If the Next.js application sends a Content Security Policy, its
178
+ `frame-ancestors` directive must allow the Directus origin. Remove conflicting
179
+ `X-Frame-Options: SAMEORIGIN` headers. Otherwise the browser will refuse to
180
+ render the site inside Directus.
181
+
182
+ ## 5. Read Draft Mode in the page
183
+
184
+ Create `app/articles/[id]/page.tsx`:
185
+
186
+ ```tsx
187
+ import { draftMode } from 'next/headers';
188
+ import { notFound } from 'next/navigation';
189
+
190
+ import { ArticlePreview } from '@/components/article-preview';
191
+ import { getArticle } from '@/lib/directus';
192
+
193
+ export default async function ArticlePage({
194
+ params,
195
+ }: {
196
+ params: Promise<{ id: string }>;
197
+ }) {
198
+ const [{ id }, { isEnabled }] = await Promise.all([params, draftMode()]);
199
+ const article = await getArticle(id, isEnabled);
200
+
201
+ if (!article) notFound();
202
+
203
+ const directusUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL;
204
+
205
+ if (!directusUrl) {
206
+ throw new Error('NEXT_PUBLIC_DIRECTUS_URL is required.');
207
+ }
208
+
209
+ return (
210
+ <ArticlePreview
211
+ key={article.id}
212
+ initialArticle={article}
213
+ directusUrl={directusUrl}
214
+ livePreviewEnabled={isEnabled}
215
+ />
216
+ );
217
+ }
218
+ ```
219
+
220
+ `draftMode()` is async in current Next.js releases. The page starts `params` and
221
+ `draftMode()` together to avoid waiting for them one at a time. The item fetch
222
+ begins after both values are available because it needs the ID and the Draft
223
+ Mode state.
224
+
225
+ ## 6. Apply unsaved edits in a client component
226
+
227
+ Create `components/article-preview.tsx`:
228
+
229
+ ```tsx
230
+ 'use client';
231
+
232
+ import { useEffect, useRef, useState } from 'react';
233
+
234
+ import type { Article } from '@/lib/directus';
235
+
236
+ const EDITS_MESSAGE_TYPE = 'directus-live-preview-bridge:edits';
237
+ const READY_MESSAGE_TYPE = 'directus-live-preview-bridge:ready';
238
+ const ALLOWED_COLLECTIONS = new Set(['articles']);
239
+
240
+ type LivePreviewMessage = {
241
+ type: typeof EDITS_MESSAGE_TYPE;
242
+ collection: string;
243
+ primaryKey: unknown;
244
+ edits: Record<string, unknown>;
245
+ initialValues?: Record<string, unknown>;
246
+ };
247
+
248
+ function useDirectusLivePreview({
249
+ directusUrl,
250
+ enabled,
251
+ onEdits,
252
+ }: {
253
+ directusUrl: string;
254
+ enabled: boolean;
255
+ onEdits: (message: LivePreviewMessage) => void;
256
+ }) {
257
+ const onEditsRef = useRef(onEdits);
258
+
259
+ useEffect(() => {
260
+ onEditsRef.current = onEdits;
261
+ }, [onEdits]);
262
+
263
+ useEffect(() => {
264
+ if (!enabled || window.parent === window) return;
265
+
266
+ const directusOrigin = new URL(directusUrl).origin;
267
+
268
+ function receiveEdits(event: MessageEvent) {
269
+ if (event.origin !== directusOrigin || event.source !== window.parent) {
270
+ return;
271
+ }
272
+
273
+ const message = event.data;
274
+
275
+ if (
276
+ !message ||
277
+ message.type !== EDITS_MESSAGE_TYPE ||
278
+ typeof message.collection !== 'string' ||
279
+ !ALLOWED_COLLECTIONS.has(message.collection) ||
280
+ typeof message.edits !== 'object' ||
281
+ message.edits === null ||
282
+ Array.isArray(message.edits) ||
283
+ (message.initialValues !== undefined &&
284
+ (typeof message.initialValues !== 'object' ||
285
+ message.initialValues === null ||
286
+ Array.isArray(message.initialValues)))
287
+ ) {
288
+ return;
289
+ }
290
+
291
+ onEditsRef.current(message);
292
+ }
293
+
294
+ window.addEventListener('message', receiveEdits);
295
+ window.parent.postMessage({ type: READY_MESSAGE_TYPE }, directusOrigin);
296
+
297
+ return () => window.removeEventListener('message', receiveEdits);
298
+ }, [directusUrl, enabled]);
299
+ }
300
+
301
+ export function ArticlePreview({
302
+ initialArticle,
303
+ directusUrl,
304
+ livePreviewEnabled,
305
+ }: {
306
+ initialArticle: Article;
307
+ directusUrl: string;
308
+ livePreviewEnabled: boolean;
309
+ }) {
310
+ const [snapshot, setSnapshot] = useState<LivePreviewMessage | null>(null);
311
+ const activeSnapshot =
312
+ livePreviewEnabled &&
313
+ String(snapshot?.primaryKey) === String(initialArticle.id)
314
+ ? snapshot
315
+ : null;
316
+ const baseline = activeSnapshot?.initialValues ?? initialArticle;
317
+ const edits = activeSnapshot?.edits ?? {};
318
+ const article = {
319
+ ...initialArticle,
320
+ title:
321
+ typeof edits.title === 'string'
322
+ ? edits.title
323
+ : typeof baseline.title === 'string'
324
+ ? baseline.title
325
+ : initialArticle.title,
326
+ body:
327
+ typeof edits.body === 'string'
328
+ ? edits.body
329
+ : typeof baseline.body === 'string'
330
+ ? baseline.body
331
+ : initialArticle.body,
332
+ };
333
+
334
+ useDirectusLivePreview({
335
+ directusUrl,
336
+ enabled: livePreviewEnabled,
337
+ onEdits(message) {
338
+ if (
339
+ message.collection !== 'articles' ||
340
+ String(message.primaryKey) !== String(initialArticle.id)
341
+ ) {
342
+ return;
343
+ }
344
+
345
+ setSnapshot(message);
346
+ },
347
+ });
348
+
349
+ return (
350
+ <main>
351
+ <h1>{article.title}</h1>
352
+ <p>{article.body}</p>
353
+ </main>
354
+ );
355
+ }
356
+ ```
357
+
358
+ The effect installs one global listener and removes it during cleanup. The
359
+ callback ref lets React use the latest edit handler without removing and adding
360
+ the window listener after every render. Each message replaces the previous
361
+ snapshot. The component derives the article from saved `initialValues` plus
362
+ current `edits`, validating each accepted field. Removing an edit restores its
363
+ saved value, and a save updates the baseline. When an older bridge omits
364
+ `initialValues`, the server-fetched article supplies the baseline.
365
+
366
+ Keep the listener in one client component near the preview page root. If many
367
+ descendants need the edited article, put the state in a React context provider
368
+ and keep this same single listener in the provider.
369
+
370
+ ## 7. Add an optional exit route
371
+
372
+ Draft Mode lasts until its cookie is cleared or the browser session ends. To
373
+ provide an explicit exit, create `app/api/draft/disable/route.ts`:
374
+
375
+ ```ts
376
+ import { draftMode } from 'next/headers';
377
+ import { redirect } from 'next/navigation';
378
+
379
+ export async function GET() {
380
+ const draft = await draftMode();
381
+ draft.disable();
382
+ redirect('/');
383
+ }
384
+ ```
385
+
386
+ Link to `/api/draft/disable` with a normal anchor. If `next/link` is used, set
387
+ `prefetch={false}` so prefetching cannot clear Draft Mode before the editor
388
+ clicks the link.
389
+
390
+ ## Cookies inside the Directus iframe
391
+
392
+ Draft Mode relies on a browser cookie. A Directus instance and preview site on
393
+ different sites can trigger third-party cookie restrictions because the Next.js
394
+ page runs inside a Directus iframe. For local HTTP testing, Next.js explicitly
395
+ requires third-party cookies and storage access to be allowed. If the cookie is
396
+ blocked, host Directus and the preview site under the same parent site or adjust
397
+ the browser policy used by editors.
398
+
399
+ You can confirm Draft Mode is active by inspecting the iframe request for the
400
+ `__prerender_bypass` cookie. You can confirm the bridge separately by watching
401
+ for `directus-live-preview-bridge:ready` and
402
+ `directus-live-preview-bridge:edits` messages in the browser developer tools.
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "directus-live-preview-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Stream unsaved Directus item edits to Live Preview frontends.",
5
+ "icon": "preview",
6
+ "keywords": [
7
+ "directus",
8
+ "directus-extension",
9
+ "directus-extension-bundle",
10
+ "live-preview"
11
+ ],
12
+ "homepage": "https://github.com/ritsa-tech/directus-live-preview-bridge#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ritsa-tech/directus-live-preview-bridge/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/ritsa-tech/directus-live-preview-bridge.git"
19
+ },
20
+ "author": {
21
+ "name": "Ritsa Tech",
22
+ "url": "https://ritsa.tech"
23
+ },
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "files": [
27
+ "dist",
28
+ "docs"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "directus:extension": {
37
+ "type": "bundle",
38
+ "partial": false,
39
+ "path": {
40
+ "app": "dist/app.js",
41
+ "api": "dist/api.js"
42
+ },
43
+ "entries": [
44
+ {
45
+ "type": "hook",
46
+ "name": "live-preview-bridge-hook",
47
+ "source": "src/hook/index.ts"
48
+ },
49
+ {
50
+ "type": "endpoint",
51
+ "name": "live-preview-bridge-endpoint",
52
+ "source": "src/endpoint/index.ts"
53
+ }
54
+ ],
55
+ "host": "^12.0.0"
56
+ },
57
+ "scripts": {
58
+ "add": "directus-extension add",
59
+ "build": "directus-extension build",
60
+ "check": "npm run typecheck && npm run test && npm run format:check && npm run build && npm run test:dist && npm run validate",
61
+ "dev": "directus-extension build --watch --no-minify",
62
+ "format": "prettier --write .",
63
+ "format:check": "prettier --check .",
64
+ "link": "directus-extension link",
65
+ "prepack": "npm run check",
66
+ "test": "vitest run",
67
+ "test:dist": "node scripts/check-dist.mjs",
68
+ "test:watch": "vitest",
69
+ "typecheck": "tsc --noEmit",
70
+ "validate": "directus-extension validate"
71
+ },
72
+ "devDependencies": {
73
+ "@directus/extensions-sdk": "18.0.4",
74
+ "@types/node": "^22.20.1",
75
+ "happy-dom": "^20.14.0",
76
+ "prettier": "^3.9.6",
77
+ "typescript": "^5.9.3",
78
+ "vitest": "^4.1.11",
79
+ "vue": "^3.5.24"
80
+ }
81
+ }