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 +21 -0
- package/README.md +205 -0
- package/dist/api.js +1 -0
- package/dist/app.js +1 -0
- package/docs/nextjs.md +402 -0
- package/package.json +81 -0
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("&","&").replaceAll('"',""").replaceAll("'","'").replaceAll("<","<").replaceAll(">",">")}"><\/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
|
+
}
|