@conradsix/c4m-preview 0.0.0-stage → 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +68 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +71 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +50 -0
- package/dist/server.js.map +1 -0
- package/package.json +51 -3
- package/src/index.ts +6 -0
- package/src/protocol.ts +153 -0
- package/src/server.ts +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brian Tyler
|
|
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
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @conradsix/c4m-preview
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Live preview for [C4M](https://github.com/conradsix/c4m). An editor opens the site's preview tab with a signed token;
|
|
4
|
+
the tab says when it's ready, and the editor sends it the unsaved page or menu over `postMessage`.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { createPreviewProtocol } from "@conradsix/c4m-preview";
|
|
8
|
+
import { signPreviewToken, verifyPreviewToken } from "@conradsix/c4m-preview/server";
|
|
9
|
+
|
|
10
|
+
const preview = createPreviewProtocol({ block: BlockSchema, menuAction: MenuActionSchema });
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `createPreviewProtocol` builds the message schemas from your block and menu action schemas. Pass `messageTypes` to
|
|
14
|
+
keep message names you already use.
|
|
15
|
+
- `@conradsix/c4m-preview/server` signs and checks preview tokens with `node:crypto`, so import it only on the server.
|
|
16
|
+
|
|
17
|
+
ESM only, with `zod` 4 as a peer dependency.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,eAAe,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { PageSeoInputSchema } from "@conradsix/c4m-schema";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
declare const PREVIEW_PROTOCOL_VERSION: 1;
|
|
4
|
+
type PreviewMessageTypes = {
|
|
5
|
+
readonly pageReady: string;
|
|
6
|
+
readonly pageSnapshot: string;
|
|
7
|
+
readonly menuReady: string;
|
|
8
|
+
readonly menuSnapshot: string;
|
|
9
|
+
};
|
|
10
|
+
declare const defaultMessageTypes: PreviewMessageTypes;
|
|
11
|
+
type PreviewMenuItem<TAction> = {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly action: TAction;
|
|
15
|
+
readonly children: readonly PreviewMenuItem<TAction>[];
|
|
16
|
+
};
|
|
17
|
+
type PreviewPageDocumentSchema<TBlock extends z.ZodType> = z.ZodObject<{
|
|
18
|
+
pageId: z.ZodUUID;
|
|
19
|
+
pageVersionId: z.ZodUUID;
|
|
20
|
+
revision: z.ZodNumber;
|
|
21
|
+
slug: z.ZodString;
|
|
22
|
+
parentPageId: z.ZodNullable<z.ZodUUID>;
|
|
23
|
+
seo: typeof PageSeoInputSchema;
|
|
24
|
+
blocks: z.ZodArray<TBlock>;
|
|
25
|
+
}>;
|
|
26
|
+
type PreviewMenuDocumentSchema<TMenuAction extends z.ZodType> = z.ZodObject<{
|
|
27
|
+
name: z.ZodString;
|
|
28
|
+
items: z.ZodArray<z.ZodType<PreviewMenuItem<z.output<TMenuAction>>>>;
|
|
29
|
+
}>;
|
|
30
|
+
type ProtocolVersion = z.ZodLiteral<typeof PREVIEW_PROTOCOL_VERSION>;
|
|
31
|
+
type PreviewProtocol<TBlock extends z.ZodType, TMenuAction extends z.ZodType> = {
|
|
32
|
+
readonly messageTypes: PreviewMessageTypes;
|
|
33
|
+
readonly PreviewPageDocumentSchema: PreviewPageDocumentSchema<TBlock>;
|
|
34
|
+
readonly PreviewReadyMessageSchema: z.ZodObject<{
|
|
35
|
+
type: z.ZodLiteral<string>;
|
|
36
|
+
protocolVersion: ProtocolVersion;
|
|
37
|
+
pageId: z.ZodUUID;
|
|
38
|
+
pageVersionId: z.ZodUUID;
|
|
39
|
+
sessionId: z.ZodUUID;
|
|
40
|
+
}>;
|
|
41
|
+
readonly PreviewSnapshotMessageSchema: z.ZodObject<{
|
|
42
|
+
type: z.ZodLiteral<string>;
|
|
43
|
+
protocolVersion: ProtocolVersion;
|
|
44
|
+
sessionId: z.ZodUUID;
|
|
45
|
+
document: PreviewPageDocumentSchema<TBlock>;
|
|
46
|
+
}>;
|
|
47
|
+
readonly PreviewMenuItemSchema: z.ZodType<PreviewMenuItem<z.output<TMenuAction>>>;
|
|
48
|
+
readonly PreviewMenuDocumentSchema: PreviewMenuDocumentSchema<TMenuAction>;
|
|
49
|
+
readonly MenuPreviewReadyMessageSchema: z.ZodObject<{
|
|
50
|
+
type: z.ZodLiteral<string>;
|
|
51
|
+
protocolVersion: ProtocolVersion;
|
|
52
|
+
sessionId: z.ZodUUID;
|
|
53
|
+
}>;
|
|
54
|
+
readonly MenuPreviewSnapshotMessageSchema: z.ZodObject<{
|
|
55
|
+
type: z.ZodLiteral<string>;
|
|
56
|
+
protocolVersion: ProtocolVersion;
|
|
57
|
+
sessionId: z.ZodUUID;
|
|
58
|
+
document: PreviewMenuDocumentSchema<TMenuAction>;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
declare const createPreviewProtocol: <TBlock extends z.ZodType, TMenuAction extends z.ZodType>(options: {
|
|
62
|
+
readonly block: TBlock;
|
|
63
|
+
readonly menuAction: TMenuAction;
|
|
64
|
+
readonly messageTypes?: Partial<PreviewMessageTypes>;
|
|
65
|
+
}) => PreviewProtocol<TBlock, TMenuAction>;
|
|
66
|
+
export { createPreviewProtocol, defaultMessageTypes, PREVIEW_PROTOCOL_VERSION };
|
|
67
|
+
export type { PreviewMenuItem, PreviewMessageTypes, PreviewProtocol };
|
|
68
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { PageSeoInputSchema } from "@conradsix/c4m-schema";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
// The editor and the site talk over postMessage: the site's preview tab says it's ready, and the editor sends it the
|
|
4
|
+
// unsaved page or menu. Both sides must agree on this version.
|
|
5
|
+
const PREVIEW_PROTOCOL_VERSION = 1;
|
|
6
|
+
const defaultMessageTypes = {
|
|
7
|
+
pageReady: "c4m:preview-ready",
|
|
8
|
+
pageSnapshot: "c4m:preview-snapshot",
|
|
9
|
+
menuReady: "c4m:menu-preview-ready",
|
|
10
|
+
menuSnapshot: "c4m:menu-preview-snapshot",
|
|
11
|
+
};
|
|
12
|
+
// The messages for one host's blocks and menu actions.
|
|
13
|
+
const createPreviewProtocol = (options) => {
|
|
14
|
+
const types = { ...defaultMessageTypes, ...options.messageTypes };
|
|
15
|
+
const PreviewPageDocumentSchema = z.object({
|
|
16
|
+
pageId: z.uuid(),
|
|
17
|
+
pageVersionId: z.uuid(),
|
|
18
|
+
revision: z.number().int().nonnegative(),
|
|
19
|
+
slug: z.string(),
|
|
20
|
+
parentPageId: z.uuid().nullable(),
|
|
21
|
+
seo: PageSeoInputSchema,
|
|
22
|
+
blocks: z.array(options.block),
|
|
23
|
+
});
|
|
24
|
+
const PreviewReadyMessageSchema = z.object({
|
|
25
|
+
type: z.literal(types.pageReady),
|
|
26
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
27
|
+
pageId: z.uuid(),
|
|
28
|
+
pageVersionId: z.uuid(),
|
|
29
|
+
sessionId: z.uuid(),
|
|
30
|
+
});
|
|
31
|
+
const PreviewSnapshotMessageSchema = z.object({
|
|
32
|
+
type: z.literal(types.pageSnapshot),
|
|
33
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
34
|
+
sessionId: z.uuid(),
|
|
35
|
+
document: PreviewPageDocumentSchema,
|
|
36
|
+
});
|
|
37
|
+
// TypeScript can't see through a lazy schema over a generic action, so the item's type is stated.
|
|
38
|
+
const PreviewMenuItemSchema = z.lazy(() => z.object({
|
|
39
|
+
key: z.string().min(1),
|
|
40
|
+
title: z.string().trim().min(1).max(200),
|
|
41
|
+
action: options.menuAction,
|
|
42
|
+
children: z.array(PreviewMenuItemSchema),
|
|
43
|
+
}));
|
|
44
|
+
const PreviewMenuDocumentSchema = z.object({
|
|
45
|
+
name: z.string().min(1),
|
|
46
|
+
items: z.array(PreviewMenuItemSchema).max(20),
|
|
47
|
+
});
|
|
48
|
+
const MenuPreviewReadyMessageSchema = z.object({
|
|
49
|
+
type: z.literal(types.menuReady),
|
|
50
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
51
|
+
sessionId: z.uuid(),
|
|
52
|
+
});
|
|
53
|
+
const MenuPreviewSnapshotMessageSchema = z.object({
|
|
54
|
+
type: z.literal(types.menuSnapshot),
|
|
55
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
56
|
+
sessionId: z.uuid(),
|
|
57
|
+
document: PreviewMenuDocumentSchema,
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
messageTypes: types,
|
|
61
|
+
PreviewPageDocumentSchema,
|
|
62
|
+
PreviewReadyMessageSchema,
|
|
63
|
+
PreviewSnapshotMessageSchema,
|
|
64
|
+
PreviewMenuItemSchema,
|
|
65
|
+
PreviewMenuDocumentSchema,
|
|
66
|
+
MenuPreviewReadyMessageSchema,
|
|
67
|
+
MenuPreviewSnapshotMessageSchema,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
export { createPreviewProtocol, defaultMessageTypes, PREVIEW_PROTOCOL_VERSION };
|
|
71
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const PagePreviewTokenClaimsSchema: z.ZodObject<{
|
|
3
|
+
kind: z.ZodLiteral<"page">;
|
|
4
|
+
pageId: z.ZodUUID;
|
|
5
|
+
pageVersionId: z.ZodUUID;
|
|
6
|
+
sessionId: z.ZodUUID;
|
|
7
|
+
expiresAt: z.ZodNumber;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
declare const MenuPreviewTokenClaimsSchema: z.ZodObject<{
|
|
10
|
+
kind: z.ZodLiteral<"menu">;
|
|
11
|
+
sessionId: z.ZodUUID;
|
|
12
|
+
path: z.ZodString;
|
|
13
|
+
expiresAt: z.ZodNumber;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
declare const PreviewTokenClaimsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
16
|
+
kind: z.ZodLiteral<"page">;
|
|
17
|
+
pageId: z.ZodUUID;
|
|
18
|
+
pageVersionId: z.ZodUUID;
|
|
19
|
+
sessionId: z.ZodUUID;
|
|
20
|
+
expiresAt: z.ZodNumber;
|
|
21
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
22
|
+
kind: z.ZodLiteral<"menu">;
|
|
23
|
+
sessionId: z.ZodUUID;
|
|
24
|
+
path: z.ZodString;
|
|
25
|
+
expiresAt: z.ZodNumber;
|
|
26
|
+
}, z.core.$strip>], "kind">;
|
|
27
|
+
type PreviewTokenClaims = z.infer<typeof PreviewTokenClaimsSchema>;
|
|
28
|
+
type PagePreviewTokenClaims = z.infer<typeof PagePreviewTokenClaimsSchema>;
|
|
29
|
+
type MenuPreviewTokenClaims = z.infer<typeof MenuPreviewTokenClaimsSchema>;
|
|
30
|
+
declare const signPreviewToken: (claims: PreviewTokenClaims, secret: string) => string;
|
|
31
|
+
declare const verifyPreviewToken: (token: string, secret: string) => PreviewTokenClaims | undefined;
|
|
32
|
+
export { signPreviewToken, verifyPreviewToken };
|
|
33
|
+
export type { MenuPreviewTokenClaims, PagePreviewTokenClaims, PreviewTokenClaims };
|
|
34
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,QAAA,MAAM,4BAA4B;;;;;;iBAMhC,CAAC;AAEH,QAAA,MAAM,4BAA4B;;;;;iBAKhC,CAAC;AAEH,QAAA,MAAM,wBAAwB;;;;;;;;;;;2BAG5B,CAAC;AAEH,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AACnE,KAAK,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAC3E,KAAK,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAK3E,QAAA,MAAM,gBAAgB,WAAY,kBAAkB,UAAU,MAAM,KAAG,MAItE,CAAC;AAEF,QAAA,MAAM,kBAAkB,UAAW,MAAM,UAAU,MAAM,KAAG,kBAAkB,GAAG,SAmBhF,CAAC;AAEF,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,CAAC;AAChD,YAAY,EAAE,sBAAsB,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,CAAC"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
// Preview tokens: the editor signs one to open the site's preview of an unsaved page or menu, and the site checks it.
|
|
4
|
+
// They use node:crypto, so they belong on the server.
|
|
5
|
+
const PagePreviewTokenClaimsSchema = z.object({
|
|
6
|
+
kind: z.literal("page"),
|
|
7
|
+
pageId: z.uuid(),
|
|
8
|
+
pageVersionId: z.uuid(),
|
|
9
|
+
sessionId: z.uuid(),
|
|
10
|
+
expiresAt: z.number().int().positive(),
|
|
11
|
+
});
|
|
12
|
+
const MenuPreviewTokenClaimsSchema = z.object({
|
|
13
|
+
kind: z.literal("menu"),
|
|
14
|
+
sessionId: z.uuid(),
|
|
15
|
+
path: z.string().startsWith("/"),
|
|
16
|
+
expiresAt: z.number().int().positive(),
|
|
17
|
+
});
|
|
18
|
+
const PreviewTokenClaimsSchema = z.discriminatedUnion("kind", [
|
|
19
|
+
PagePreviewTokenClaimsSchema,
|
|
20
|
+
MenuPreviewTokenClaimsSchema,
|
|
21
|
+
]);
|
|
22
|
+
const signatureFor = (payload, secret) => createHmac("sha256", secret).update(payload).digest("base64url");
|
|
23
|
+
const signPreviewToken = (claims, secret) => {
|
|
24
|
+
const parsed = PreviewTokenClaimsSchema.parse(claims);
|
|
25
|
+
const payload = Buffer.from(JSON.stringify(parsed), "utf8").toString("base64url");
|
|
26
|
+
return `${payload}.${signatureFor(payload, secret)}`;
|
|
27
|
+
};
|
|
28
|
+
const verifyPreviewToken = (token, secret) => {
|
|
29
|
+
const [payload, signature, unexpected] = token.split(".");
|
|
30
|
+
if (!payload || !signature || unexpected)
|
|
31
|
+
return undefined;
|
|
32
|
+
const expected = signatureFor(payload, secret);
|
|
33
|
+
const actualBytes = Buffer.from(signature, "utf8");
|
|
34
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
35
|
+
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
40
|
+
const parsed = PreviewTokenClaimsSchema.safeParse(decoded);
|
|
41
|
+
if (!parsed.success || parsed.data.expiresAt <= Date.now())
|
|
42
|
+
return undefined;
|
|
43
|
+
return parsed.data;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
export { signPreviewToken, verifyPreviewToken };
|
|
50
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conradsix/c4m-preview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-preview.1",
|
|
4
|
+
"description": "Live preview for C4M: the editor-to-site postMessage protocol and signed preview tokens.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"c4m",
|
|
7
|
+
"cms",
|
|
8
|
+
"preview"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Brian Tyler",
|
|
12
|
+
"homepage": "https://github.com/conradsix/c4m#readme",
|
|
13
|
+
"bugs": "https://github.com/conradsix/c4m/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/conradsix/c4m.git",
|
|
17
|
+
"directory": "typescript/packages/c4m-preview"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./server": {
|
|
27
|
+
"types": "./dist/server.d.ts",
|
|
28
|
+
"import": "./dist/server.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"check:types": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@conradsix/c4m-schema": "^0.1.0-preview.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"zod": "^4.0.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "26.6.5",
|
|
49
|
+
"typescript": "7.0.2",
|
|
50
|
+
"vite": "8.3.5",
|
|
51
|
+
"vitest": "5.0.5",
|
|
52
|
+
"zod": "4.6.7"
|
|
53
|
+
}
|
|
6
54
|
}
|
package/src/index.ts
ADDED
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { PageSeoInputSchema } from "@conradsix/c4m-schema";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// The editor and the site talk over postMessage: the site's preview tab says it's ready, and the editor sends it the
|
|
5
|
+
// unsaved page or menu. Both sides must agree on this version.
|
|
6
|
+
const PREVIEW_PROTOCOL_VERSION = 1 as const;
|
|
7
|
+
|
|
8
|
+
// The postMessage type names. A host that already shipped names of its own keeps them, so its editor and site can
|
|
9
|
+
// deploy apart.
|
|
10
|
+
type PreviewMessageTypes = {
|
|
11
|
+
readonly pageReady: string;
|
|
12
|
+
readonly pageSnapshot: string;
|
|
13
|
+
readonly menuReady: string;
|
|
14
|
+
readonly menuSnapshot: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const defaultMessageTypes: PreviewMessageTypes = {
|
|
18
|
+
pageReady: "c4m:preview-ready",
|
|
19
|
+
pageSnapshot: "c4m:preview-snapshot",
|
|
20
|
+
menuReady: "c4m:menu-preview-ready",
|
|
21
|
+
menuSnapshot: "c4m:menu-preview-snapshot",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type PreviewMenuItem<TAction> = {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly action: TAction;
|
|
28
|
+
readonly children: readonly PreviewMenuItem<TAction>[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type PreviewPageDocumentSchema<TBlock extends z.ZodType> = z.ZodObject<{
|
|
32
|
+
pageId: z.ZodUUID;
|
|
33
|
+
pageVersionId: z.ZodUUID;
|
|
34
|
+
revision: z.ZodNumber;
|
|
35
|
+
slug: z.ZodString;
|
|
36
|
+
parentPageId: z.ZodNullable<z.ZodUUID>;
|
|
37
|
+
seo: typeof PageSeoInputSchema;
|
|
38
|
+
blocks: z.ZodArray<TBlock>;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
type PreviewMenuDocumentSchema<TMenuAction extends z.ZodType> = z.ZodObject<{
|
|
42
|
+
name: z.ZodString;
|
|
43
|
+
items: z.ZodArray<z.ZodType<PreviewMenuItem<z.output<TMenuAction>>>>;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
type ProtocolVersion = z.ZodLiteral<typeof PREVIEW_PROTOCOL_VERSION>;
|
|
47
|
+
|
|
48
|
+
type PreviewProtocol<TBlock extends z.ZodType, TMenuAction extends z.ZodType> = {
|
|
49
|
+
readonly messageTypes: PreviewMessageTypes;
|
|
50
|
+
readonly PreviewPageDocumentSchema: PreviewPageDocumentSchema<TBlock>;
|
|
51
|
+
readonly PreviewReadyMessageSchema: z.ZodObject<{
|
|
52
|
+
type: z.ZodLiteral<string>;
|
|
53
|
+
protocolVersion: ProtocolVersion;
|
|
54
|
+
pageId: z.ZodUUID;
|
|
55
|
+
pageVersionId: z.ZodUUID;
|
|
56
|
+
sessionId: z.ZodUUID;
|
|
57
|
+
}>;
|
|
58
|
+
readonly PreviewSnapshotMessageSchema: z.ZodObject<{
|
|
59
|
+
type: z.ZodLiteral<string>;
|
|
60
|
+
protocolVersion: ProtocolVersion;
|
|
61
|
+
sessionId: z.ZodUUID;
|
|
62
|
+
document: PreviewPageDocumentSchema<TBlock>;
|
|
63
|
+
}>;
|
|
64
|
+
readonly PreviewMenuItemSchema: z.ZodType<PreviewMenuItem<z.output<TMenuAction>>>;
|
|
65
|
+
readonly PreviewMenuDocumentSchema: PreviewMenuDocumentSchema<TMenuAction>;
|
|
66
|
+
readonly MenuPreviewReadyMessageSchema: z.ZodObject<{
|
|
67
|
+
type: z.ZodLiteral<string>;
|
|
68
|
+
protocolVersion: ProtocolVersion;
|
|
69
|
+
sessionId: z.ZodUUID;
|
|
70
|
+
}>;
|
|
71
|
+
readonly MenuPreviewSnapshotMessageSchema: z.ZodObject<{
|
|
72
|
+
type: z.ZodLiteral<string>;
|
|
73
|
+
protocolVersion: ProtocolVersion;
|
|
74
|
+
sessionId: z.ZodUUID;
|
|
75
|
+
document: PreviewMenuDocumentSchema<TMenuAction>;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The messages for one host's blocks and menu actions.
|
|
80
|
+
const createPreviewProtocol = <TBlock extends z.ZodType, TMenuAction extends z.ZodType>(options: {
|
|
81
|
+
readonly block: TBlock;
|
|
82
|
+
readonly menuAction: TMenuAction;
|
|
83
|
+
readonly messageTypes?: Partial<PreviewMessageTypes>;
|
|
84
|
+
}): PreviewProtocol<TBlock, TMenuAction> => {
|
|
85
|
+
const types = { ...defaultMessageTypes, ...options.messageTypes };
|
|
86
|
+
|
|
87
|
+
const PreviewPageDocumentSchema = z.object({
|
|
88
|
+
pageId: z.uuid(),
|
|
89
|
+
pageVersionId: z.uuid(),
|
|
90
|
+
revision: z.number().int().nonnegative(),
|
|
91
|
+
slug: z.string(),
|
|
92
|
+
parentPageId: z.uuid().nullable(),
|
|
93
|
+
seo: PageSeoInputSchema,
|
|
94
|
+
blocks: z.array(options.block),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const PreviewReadyMessageSchema = z.object({
|
|
98
|
+
type: z.literal(types.pageReady),
|
|
99
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
100
|
+
pageId: z.uuid(),
|
|
101
|
+
pageVersionId: z.uuid(),
|
|
102
|
+
sessionId: z.uuid(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const PreviewSnapshotMessageSchema = z.object({
|
|
106
|
+
type: z.literal(types.pageSnapshot),
|
|
107
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
108
|
+
sessionId: z.uuid(),
|
|
109
|
+
document: PreviewPageDocumentSchema,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// TypeScript can't see through a lazy schema over a generic action, so the item's type is stated.
|
|
113
|
+
const PreviewMenuItemSchema = z.lazy(() =>
|
|
114
|
+
z.object({
|
|
115
|
+
key: z.string().min(1),
|
|
116
|
+
title: z.string().trim().min(1).max(200),
|
|
117
|
+
action: options.menuAction,
|
|
118
|
+
children: z.array(PreviewMenuItemSchema),
|
|
119
|
+
}),
|
|
120
|
+
) as unknown as z.ZodType<PreviewMenuItem<z.output<TMenuAction>>>;
|
|
121
|
+
|
|
122
|
+
const PreviewMenuDocumentSchema = z.object({
|
|
123
|
+
name: z.string().min(1),
|
|
124
|
+
items: z.array(PreviewMenuItemSchema).max(20),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const MenuPreviewReadyMessageSchema = z.object({
|
|
128
|
+
type: z.literal(types.menuReady),
|
|
129
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
130
|
+
sessionId: z.uuid(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const MenuPreviewSnapshotMessageSchema = z.object({
|
|
134
|
+
type: z.literal(types.menuSnapshot),
|
|
135
|
+
protocolVersion: z.literal(PREVIEW_PROTOCOL_VERSION),
|
|
136
|
+
sessionId: z.uuid(),
|
|
137
|
+
document: PreviewMenuDocumentSchema,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
messageTypes: types,
|
|
142
|
+
PreviewPageDocumentSchema,
|
|
143
|
+
PreviewReadyMessageSchema,
|
|
144
|
+
PreviewSnapshotMessageSchema,
|
|
145
|
+
PreviewMenuItemSchema,
|
|
146
|
+
PreviewMenuDocumentSchema,
|
|
147
|
+
MenuPreviewReadyMessageSchema,
|
|
148
|
+
MenuPreviewSnapshotMessageSchema,
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export { createPreviewProtocol, defaultMessageTypes, PREVIEW_PROTOCOL_VERSION };
|
|
153
|
+
export type { PreviewMenuItem, PreviewMessageTypes, PreviewProtocol };
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
// Preview tokens: the editor signs one to open the site's preview of an unsaved page or menu, and the site checks it.
|
|
6
|
+
// They use node:crypto, so they belong on the server.
|
|
7
|
+
const PagePreviewTokenClaimsSchema = z.object({
|
|
8
|
+
kind: z.literal("page"),
|
|
9
|
+
pageId: z.uuid(),
|
|
10
|
+
pageVersionId: z.uuid(),
|
|
11
|
+
sessionId: z.uuid(),
|
|
12
|
+
expiresAt: z.number().int().positive(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const MenuPreviewTokenClaimsSchema = z.object({
|
|
16
|
+
kind: z.literal("menu"),
|
|
17
|
+
sessionId: z.uuid(),
|
|
18
|
+
path: z.string().startsWith("/"),
|
|
19
|
+
expiresAt: z.number().int().positive(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const PreviewTokenClaimsSchema = z.discriminatedUnion("kind", [
|
|
23
|
+
PagePreviewTokenClaimsSchema,
|
|
24
|
+
MenuPreviewTokenClaimsSchema,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
type PreviewTokenClaims = z.infer<typeof PreviewTokenClaimsSchema>;
|
|
28
|
+
type PagePreviewTokenClaims = z.infer<typeof PagePreviewTokenClaimsSchema>;
|
|
29
|
+
type MenuPreviewTokenClaims = z.infer<typeof MenuPreviewTokenClaimsSchema>;
|
|
30
|
+
|
|
31
|
+
const signatureFor = (payload: string, secret: string): string =>
|
|
32
|
+
createHmac("sha256", secret).update(payload).digest("base64url");
|
|
33
|
+
|
|
34
|
+
const signPreviewToken = (claims: PreviewTokenClaims, secret: string): string => {
|
|
35
|
+
const parsed = PreviewTokenClaimsSchema.parse(claims);
|
|
36
|
+
const payload = Buffer.from(JSON.stringify(parsed), "utf8").toString("base64url");
|
|
37
|
+
return `${payload}.${signatureFor(payload, secret)}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const verifyPreviewToken = (token: string, secret: string): PreviewTokenClaims | undefined => {
|
|
41
|
+
const [payload, signature, unexpected] = token.split(".");
|
|
42
|
+
if (!payload || !signature || unexpected) return undefined;
|
|
43
|
+
|
|
44
|
+
const expected = signatureFor(payload, secret);
|
|
45
|
+
const actualBytes = Buffer.from(signature, "utf8");
|
|
46
|
+
const expectedBytes = Buffer.from(expected, "utf8");
|
|
47
|
+
if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const decoded: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
53
|
+
const parsed = PreviewTokenClaimsSchema.safeParse(decoded);
|
|
54
|
+
if (!parsed.success || parsed.data.expiresAt <= Date.now()) return undefined;
|
|
55
|
+
return parsed.data;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { signPreviewToken, verifyPreviewToken };
|
|
62
|
+
export type { MenuPreviewTokenClaims, PagePreviewTokenClaims, PreviewTokenClaims };
|