@mapled/next 0.1.0 → 0.3.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/README.md +39 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +99 -7
- package/dist/server.d.ts +8 -0
- package/dist/server.js +53 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,45 @@ export const POST = createRevalidateHandler({
|
|
|
54
54
|
|
|
55
55
|
Then in Mapled (project → Integrations → **Your site**) set the revalidation webhook to `https://your-site.com/api/mapled/revalidate` and copy the signing secret into `MAPLED_WEBHOOK_SECRET`. Every publish sends a signed ping; the handler verifies the `x-mapled-signature` HMAC and revalidates the `mapled` tags.
|
|
56
56
|
|
|
57
|
+
## Preview drafts
|
|
58
|
+
|
|
59
|
+
Mount the preview routes:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// app/api/mapled/preview/route.ts
|
|
63
|
+
import { createPreviewHandler } from "@mapled/next/server";
|
|
64
|
+
export const GET = createPreviewHandler();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// app/api/mapled/preview/exit/route.ts
|
|
69
|
+
import { createExitPreviewHandler } from "@mapled/next/server";
|
|
70
|
+
export const GET = createExitPreviewHandler();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The **Preview** button in Mapled opens your site through a one-time link: the route turns on Next.js draft mode, and every `getRecords`/`getRecord`/`getSingle` call automatically switches to live drafts, uncached. No code changes in your components — the client detects draft mode on its own. Sessions last two hours; `/api/mapled/preview/exit` leaves preview.
|
|
74
|
+
|
|
75
|
+
## Live data
|
|
76
|
+
|
|
77
|
+
Operational collections skip the publish cycle — saves take effect immediately. Your site's backend reads **and writes** them through the Application Data API with the project's **server key** (Mapled: Integrations → Your site). Server-side only; the key must never reach the browser.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
MAPLED_SERVER_KEY=msk_live_…
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createAppClient } from "@mapled/next";
|
|
85
|
+
|
|
86
|
+
const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
|
|
87
|
+
|
|
88
|
+
await db.create("orders", { customer: "Ada Lovelace", total: 120 });
|
|
89
|
+
const { records } = await db.list("orders", { filter: { status: "new" }, sort: "-total" });
|
|
90
|
+
await db.update("orders", id, { ...data, status: "shipped" });
|
|
91
|
+
await db.remove("orders", id);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The server key reaches only operational collections with access class `public`/`server` — editorial content, internal and sensitive collections stay out of reach, and it grants nothing on the Management API.
|
|
95
|
+
|
|
57
96
|
## API
|
|
58
97
|
|
|
59
98
|
- `createClient({ key, apiUrl? })` → `getRecords(collection, opts?)`, `getRecord(collection, id, opts?)`, `getSingle(key, opts?)`
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,19 @@ export declare class MapledError extends Error {
|
|
|
28
28
|
constructor(status: number, message: string);
|
|
29
29
|
}
|
|
30
30
|
export type MapledClient = ReturnType<typeof createClient>;
|
|
31
|
+
export type MapledAppClient = ReturnType<typeof createAppClient>;
|
|
32
|
+
export type AppRecord<T = Record<string, unknown>> = {
|
|
33
|
+
id: string;
|
|
34
|
+
data: T;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
};
|
|
38
|
+
export type AppListOptions = {
|
|
39
|
+
filter?: Record<string, string | number | boolean>;
|
|
40
|
+
sort?: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
offset?: number;
|
|
43
|
+
};
|
|
31
44
|
export declare function createClient(config: {
|
|
32
45
|
key: string;
|
|
33
46
|
apiUrl?: string;
|
|
@@ -39,3 +52,19 @@ export declare function createClient(config: {
|
|
|
39
52
|
/** The record of a single (e.g. "homepage"), or null before publish. */
|
|
40
53
|
getSingle<T = Record<string, unknown>>(key: string, opts?: Pick<ListOptions, "revalidate" | "tags">): Promise<MapledRecord<T> | null>;
|
|
41
54
|
};
|
|
55
|
+
/** Live data (operational collections): the site's backend reads and
|
|
56
|
+
writes records that take effect immediately — no publish involved.
|
|
57
|
+
Server-side only: the server key must never reach the browser. */
|
|
58
|
+
export declare function createAppClient(config: {
|
|
59
|
+
serverKey: string;
|
|
60
|
+
apiUrl?: string;
|
|
61
|
+
}): {
|
|
62
|
+
list<T = Record<string, unknown>>(collection: string, opts?: AppListOptions): Promise<{
|
|
63
|
+
records: AppRecord<T>[];
|
|
64
|
+
total: number;
|
|
65
|
+
}>;
|
|
66
|
+
get<T = Record<string, unknown>>(collection: string, id: string): Promise<AppRecord<T> | null>;
|
|
67
|
+
create<T = Record<string, unknown>>(collection: string, data: T): Promise<AppRecord<T>>;
|
|
68
|
+
update<T = Record<string, unknown>>(collection: string, id: string, data: T): Promise<AppRecord<T>>;
|
|
69
|
+
remove(collection: string, id: string): Promise<void>;
|
|
70
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -9,19 +9,40 @@ export class MapledError extends Error {
|
|
|
9
9
|
this.status = status;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/** The draft session cookie set by the preview route. When Next draft
|
|
13
|
+
mode is on, every read switches to live drafts, uncached. Outside a
|
|
14
|
+
Next request context this quietly resolves to null. */
|
|
15
|
+
async function draftSession() {
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import("next/headers");
|
|
18
|
+
if (!(await mod.draftMode()).isEnabled)
|
|
19
|
+
return null;
|
|
20
|
+
const jar = await mod.cookies();
|
|
21
|
+
return jar.get("mapled_draft")?.value ?? null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
12
27
|
export function createClient(config) {
|
|
13
28
|
const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
14
29
|
if (!config.key)
|
|
15
30
|
throw new Error("Mapled: a delivery key is required.");
|
|
16
31
|
async function call(path, search, opts, cacheTags) {
|
|
17
32
|
const qs = search.toString();
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
33
|
+
const draft = await draftSession();
|
|
34
|
+
const init = draft
|
|
35
|
+
? {
|
|
36
|
+
headers: { "x-mapled-key": config.key, "x-mapled-draft": draft },
|
|
37
|
+
cache: "no-store",
|
|
38
|
+
}
|
|
39
|
+
: {
|
|
40
|
+
headers: { "x-mapled-key": config.key },
|
|
41
|
+
next: {
|
|
42
|
+
revalidate: opts.revalidate ?? 3600,
|
|
43
|
+
tags: ["mapled", ...cacheTags, ...(opts.tags ?? [])],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
25
46
|
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, init);
|
|
26
47
|
if (!res.ok) {
|
|
27
48
|
let message = `Mapled request failed (${res.status}).`;
|
|
@@ -79,3 +100,74 @@ export function createClient(config) {
|
|
|
79
100
|
},
|
|
80
101
|
};
|
|
81
102
|
}
|
|
103
|
+
/** Live data (operational collections): the site's backend reads and
|
|
104
|
+
writes records that take effect immediately — no publish involved.
|
|
105
|
+
Server-side only: the server key must never reach the browser. */
|
|
106
|
+
export function createAppClient(config) {
|
|
107
|
+
const base = (config.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
108
|
+
if (!config.serverKey)
|
|
109
|
+
throw new Error("Mapled: a server key is required.");
|
|
110
|
+
async function call(method, path, search, data) {
|
|
111
|
+
const qs = search?.toString();
|
|
112
|
+
const res = await fetch(`${base}${path}${qs ? `?${qs}` : ""}`, {
|
|
113
|
+
method,
|
|
114
|
+
headers: {
|
|
115
|
+
"x-mapled-server-key": config.serverKey,
|
|
116
|
+
...(data !== undefined ? { "content-type": "application/json" } : {}),
|
|
117
|
+
},
|
|
118
|
+
...(data !== undefined ? { body: JSON.stringify({ data }) } : {}),
|
|
119
|
+
cache: "no-store",
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
let message = `Mapled request failed (${res.status}).`;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = (await res.json());
|
|
125
|
+
if (parsed.error?.message)
|
|
126
|
+
message = parsed.error.message;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* non-JSON error body */
|
|
130
|
+
}
|
|
131
|
+
throw new MapledError(res.status, message);
|
|
132
|
+
}
|
|
133
|
+
return (await res.json());
|
|
134
|
+
}
|
|
135
|
+
const collectionPath = (c) => `/v1/app/collections/${encodeURIComponent(c)}/records`;
|
|
136
|
+
return {
|
|
137
|
+
async list(collection, opts = {}) {
|
|
138
|
+
const search = new URLSearchParams();
|
|
139
|
+
for (const [k, v] of Object.entries(opts.filter ?? {})) {
|
|
140
|
+
search.set(`filter[${k}]`, String(v));
|
|
141
|
+
}
|
|
142
|
+
if (opts.sort)
|
|
143
|
+
search.set("sort", opts.sort);
|
|
144
|
+
if (opts.limit !== undefined)
|
|
145
|
+
search.set("limit", String(opts.limit));
|
|
146
|
+
if (opts.offset !== undefined)
|
|
147
|
+
search.set("offset", String(opts.offset));
|
|
148
|
+
return call("GET", collectionPath(collection), search);
|
|
149
|
+
},
|
|
150
|
+
async get(collection, id) {
|
|
151
|
+
try {
|
|
152
|
+
const body = await call("GET", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
|
|
153
|
+
return body.record;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err instanceof MapledError && err.status === 404)
|
|
157
|
+
return null;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
async create(collection, data) {
|
|
162
|
+
const body = await call("POST", collectionPath(collection), undefined, data);
|
|
163
|
+
return body.record;
|
|
164
|
+
},
|
|
165
|
+
async update(collection, id, data) {
|
|
166
|
+
const body = await call("PATCH", `${collectionPath(collection)}/${encodeURIComponent(id)}`, undefined, data);
|
|
167
|
+
return body.record;
|
|
168
|
+
},
|
|
169
|
+
async remove(collection, id) {
|
|
170
|
+
await call("DELETE", `${collectionPath(collection)}/${encodeURIComponent(id)}`);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
then refreshes all "mapled"-tagged fetches instantly. */
|
|
11
11
|
/** Constant-time check of the "x-mapled-signature" header value. */
|
|
12
12
|
export declare function verifySignature(secret: string, body: string, signature: string): Promise<boolean>;
|
|
13
|
+
/** GET handler for the preview route (mount at /api/mapled/preview).
|
|
14
|
+
Trades the one-time link token from Mapled for a draft session,
|
|
15
|
+
turns on Next draft mode, and redirects into the site. */
|
|
16
|
+
export declare function createPreviewHandler(options?: {
|
|
17
|
+
apiUrl?: string;
|
|
18
|
+
}): (req: Request) => Promise<Response>;
|
|
19
|
+
/** GET handler to leave preview (mount at /api/mapled/preview/exit). */
|
|
20
|
+
export declare function createExitPreviewHandler(): (req: Request) => Promise<Response>;
|
|
13
21
|
export declare function createRevalidateHandler(options: {
|
|
14
22
|
secret: string;
|
|
15
23
|
/** Cache tags to refresh — defaults to ["mapled"], which covers every
|
package/dist/server.js
CHANGED
|
@@ -27,6 +27,59 @@ export async function verifySignature(secret, body, signature) {
|
|
|
27
27
|
}
|
|
28
28
|
return diff === 0;
|
|
29
29
|
}
|
|
30
|
+
const DRAFT_COOKIE = "mapled_draft";
|
|
31
|
+
/** Same-app relative path only — preview links can't bounce elsewhere. */
|
|
32
|
+
function safeNext(raw) {
|
|
33
|
+
return raw && /^\/(?!\/)/.test(raw) ? raw : "/";
|
|
34
|
+
}
|
|
35
|
+
/** GET handler for the preview route (mount at /api/mapled/preview).
|
|
36
|
+
Trades the one-time link token from Mapled for a draft session,
|
|
37
|
+
turns on Next draft mode, and redirects into the site. */
|
|
38
|
+
export function createPreviewHandler(options = {}) {
|
|
39
|
+
const base = (options.apiUrl ?? "https://api.mapled.io").replace(/\/+$/, "");
|
|
40
|
+
return async function GET(req) {
|
|
41
|
+
const url = new URL(req.url);
|
|
42
|
+
const token = url.searchParams.get("token");
|
|
43
|
+
if (!token)
|
|
44
|
+
return new Response("Missing preview token.", { status: 400 });
|
|
45
|
+
const res = await fetch(`${base}/v1/delivery/preview/exchange`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({ token }),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
return new Response("This preview link has expired. Open it from Mapled again.", {
|
|
52
|
+
status: 401,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const { draftToken, expiresAt } = (await res.json());
|
|
56
|
+
const { draftMode, cookies } = await import("next/headers");
|
|
57
|
+
(await draftMode()).enable();
|
|
58
|
+
(await cookies()).set(DRAFT_COOKIE, draftToken, {
|
|
59
|
+
httpOnly: true,
|
|
60
|
+
secure: true,
|
|
61
|
+
sameSite: "lax",
|
|
62
|
+
path: "/",
|
|
63
|
+
expires: new Date(expiresAt),
|
|
64
|
+
});
|
|
65
|
+
return new Response(null, {
|
|
66
|
+
status: 307,
|
|
67
|
+
headers: { location: safeNext(url.searchParams.get("next")) },
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** GET handler to leave preview (mount at /api/mapled/preview/exit). */
|
|
72
|
+
export function createExitPreviewHandler() {
|
|
73
|
+
return async function GET(req) {
|
|
74
|
+
const { draftMode, cookies } = await import("next/headers");
|
|
75
|
+
(await draftMode()).disable();
|
|
76
|
+
(await cookies()).delete(DRAFT_COOKIE);
|
|
77
|
+
return new Response(null, {
|
|
78
|
+
status: 307,
|
|
79
|
+
headers: { location: safeNext(new URL(req.url).searchParams.get("next")) },
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
30
83
|
export function createRevalidateHandler(options) {
|
|
31
84
|
if (!options.secret) {
|
|
32
85
|
throw new Error("Mapled: the webhook secret is required.");
|
package/package.json
CHANGED