editor-ts 0.0.10 → 0.0.12
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 +197 -318
- package/index.ts +70 -0
- package/package.json +31 -10
- package/src/core/ComponentManager.ts +697 -6
- package/src/core/ComponentPalette.ts +109 -0
- package/src/core/CustomComponentRegistry.ts +74 -0
- package/src/core/KeyboardShortcuts.ts +220 -0
- package/src/core/LayerManager.ts +378 -0
- package/src/core/Page.ts +24 -5
- package/src/core/StorageManager.ts +447 -0
- package/src/core/StyleManager.ts +38 -2
- package/src/core/VersionControl.ts +189 -0
- package/src/core/aiChat.ts +427 -0
- package/src/core/iframeCanvas.ts +672 -0
- package/src/core/init.ts +3081 -248
- package/src/server/bun_server.ts +155 -0
- package/src/server/cf_worker.ts +225 -0
- package/src/server/schema.ts +21 -0
- package/src/server/sync.ts +195 -0
- package/src/types/sqlocal.d.ts +6 -0
- package/src/types.ts +591 -18
- package/src/utils/toolbar.ts +15 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { Server } from 'bun';
|
|
3
|
+
import type { PageMeta, PageMetaStore } from './sync';
|
|
4
|
+
import type { EditorTsSyncMessage } from '../types';
|
|
5
|
+
import { createSyncAck, isSyncMessage, parseSyncEnvelope } from './sync';
|
|
6
|
+
|
|
7
|
+
export interface BunSyncServer {
|
|
8
|
+
server: Server<unknown>;
|
|
9
|
+
close: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const createBunPageMetaStore = (database: Database): PageMetaStore => {
|
|
13
|
+
const init = () => {
|
|
14
|
+
database.query(
|
|
15
|
+
`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS editorts_page_meta (
|
|
17
|
+
key TEXT PRIMARY KEY,
|
|
18
|
+
title TEXT NOT NULL,
|
|
19
|
+
item_id INTEGER NOT NULL,
|
|
20
|
+
updated_at TEXT NOT NULL
|
|
21
|
+
)
|
|
22
|
+
`
|
|
23
|
+
).run();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const toRow = (meta: PageMeta) => ({
|
|
27
|
+
key: meta.key,
|
|
28
|
+
title: meta.title,
|
|
29
|
+
item_id: meta.itemId,
|
|
30
|
+
updated_at: meta.updatedAt,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const fromRow = (row: {
|
|
34
|
+
key: string;
|
|
35
|
+
title: string;
|
|
36
|
+
item_id: number;
|
|
37
|
+
updated_at: string;
|
|
38
|
+
}): PageMeta => ({
|
|
39
|
+
key: row.key,
|
|
40
|
+
title: row.title,
|
|
41
|
+
itemId: row.item_id,
|
|
42
|
+
updatedAt: row.updated_at,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
init();
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async save(meta) {
|
|
49
|
+
const row = toRow(meta);
|
|
50
|
+
database
|
|
51
|
+
.query(
|
|
52
|
+
`
|
|
53
|
+
INSERT INTO editorts_page_meta (key, title, item_id, updated_at)
|
|
54
|
+
VALUES ($key, $title, $item_id, $updated_at)
|
|
55
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
56
|
+
title = excluded.title,
|
|
57
|
+
item_id = excluded.item_id,
|
|
58
|
+
updated_at = excluded.updated_at
|
|
59
|
+
`
|
|
60
|
+
)
|
|
61
|
+
.run(row);
|
|
62
|
+
},
|
|
63
|
+
async get(key) {
|
|
64
|
+
const row = database
|
|
65
|
+
.query(
|
|
66
|
+
`
|
|
67
|
+
SELECT key, title, item_id, updated_at
|
|
68
|
+
FROM editorts_page_meta
|
|
69
|
+
WHERE key = $key
|
|
70
|
+
`
|
|
71
|
+
)
|
|
72
|
+
.get({ key }) as
|
|
73
|
+
| {
|
|
74
|
+
key: string;
|
|
75
|
+
title: string;
|
|
76
|
+
item_id: number;
|
|
77
|
+
updated_at: string;
|
|
78
|
+
}
|
|
79
|
+
| null;
|
|
80
|
+
|
|
81
|
+
return row ? fromRow(row) : null;
|
|
82
|
+
},
|
|
83
|
+
async list() {
|
|
84
|
+
const rows = database
|
|
85
|
+
.query(
|
|
86
|
+
`
|
|
87
|
+
SELECT key, title, item_id, updated_at
|
|
88
|
+
FROM editorts_page_meta
|
|
89
|
+
ORDER BY updated_at DESC
|
|
90
|
+
`
|
|
91
|
+
)
|
|
92
|
+
.all() as Array<{ key: string; title: string; item_id: number; updated_at: string }>;
|
|
93
|
+
|
|
94
|
+
return rows.map((row) => fromRow(row));
|
|
95
|
+
},
|
|
96
|
+
async delete(key) {
|
|
97
|
+
database
|
|
98
|
+
.query(
|
|
99
|
+
`
|
|
100
|
+
DELETE FROM editorts_page_meta
|
|
101
|
+
WHERE key = $key
|
|
102
|
+
`
|
|
103
|
+
)
|
|
104
|
+
.run({ key });
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const createBunSyncServer = (options?: {
|
|
110
|
+
port?: number;
|
|
111
|
+
onSync?: (message: EditorTsSyncMessage) => Promise<void> | void;
|
|
112
|
+
}): BunSyncServer => {
|
|
113
|
+
const port = options?.port ?? 8787;
|
|
114
|
+
const onSync = options?.onSync;
|
|
115
|
+
|
|
116
|
+
const server = Bun.serve({
|
|
117
|
+
port,
|
|
118
|
+
fetch(req) {
|
|
119
|
+
const upgradeHeader = req.headers.get('upgrade');
|
|
120
|
+
if (upgradeHeader?.toLowerCase() !== 'websocket') {
|
|
121
|
+
return new Response('Upgrade required', { status: 426 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!server.upgrade(req)) {
|
|
125
|
+
return new Response('Websocket upgrade failed', { status: 400 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Response(null, { status: 101 });
|
|
129
|
+
},
|
|
130
|
+
websocket: {
|
|
131
|
+
open(ws) {
|
|
132
|
+
ws.send(JSON.stringify(createSyncAck('connected')));
|
|
133
|
+
},
|
|
134
|
+
async message(ws, data) {
|
|
135
|
+
const text = typeof data === 'string' ? data : new TextDecoder().decode(data as BufferSource);
|
|
136
|
+
const envelope = parseSyncEnvelope(text);
|
|
137
|
+
if (!envelope || !isSyncMessage(envelope)) {
|
|
138
|
+
ws.send(JSON.stringify(createSyncAck('invalid-payload')));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (onSync) {
|
|
143
|
+
await onSync(envelope);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ws.send(JSON.stringify(createSyncAck(envelope.key ?? 'page')));
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
server,
|
|
153
|
+
close: () => server.stop(true),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { PageMeta, PageMetaStore } from './sync';
|
|
2
|
+
import type { EditorTsSyncMessage } from '../types';
|
|
3
|
+
import { createSyncAck, isSyncMessage, parseSyncEnvelope } from './sync';
|
|
4
|
+
|
|
5
|
+
type DurableObjectNamespace = {
|
|
6
|
+
idFromName(name: string): DurableObjectId;
|
|
7
|
+
get(id: DurableObjectId): DurableObjectStub;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type DurableObjectId = {
|
|
11
|
+
toString(): string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type DurableObjectStub = {
|
|
15
|
+
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type DurableObjectStorage = {
|
|
19
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
20
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
21
|
+
delete(key: string): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DurableObjectState = {
|
|
25
|
+
id: DurableObjectId;
|
|
26
|
+
storage: DurableObjectStorage;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type DurableObject = {
|
|
30
|
+
fetch(request: Request): Promise<Response>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createCfPageMetaStore = (namespace: DurableObjectNamespace): PageMetaStore => {
|
|
34
|
+
return {
|
|
35
|
+
async save(meta) {
|
|
36
|
+
const id = namespace.idFromName(meta.key);
|
|
37
|
+
const stub = namespace.get(id);
|
|
38
|
+
await stub.fetch('https://editor-ts.local/meta', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(meta),
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
async get(key) {
|
|
45
|
+
const id = namespace.idFromName(key);
|
|
46
|
+
const stub = namespace.get(id);
|
|
47
|
+
const response = await stub.fetch('https://editor-ts.local/meta');
|
|
48
|
+
if (response.status === 404) return null;
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`Failed to fetch page meta: ${response.statusText}`);
|
|
51
|
+
}
|
|
52
|
+
return (await response.json()) as PageMeta;
|
|
53
|
+
},
|
|
54
|
+
async list() {
|
|
55
|
+
const id = namespace.idFromName('index');
|
|
56
|
+
const stub = namespace.get(id);
|
|
57
|
+
const response = await stub.fetch('https://editor-ts.local/meta/index');
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`Failed to list page meta: ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
return (await response.json()) as PageMeta[];
|
|
62
|
+
},
|
|
63
|
+
async delete(key) {
|
|
64
|
+
const id = namespace.idFromName(key);
|
|
65
|
+
const stub = namespace.get(id);
|
|
66
|
+
const response = await stub.fetch('https://editor-ts.local/meta', {
|
|
67
|
+
method: 'DELETE',
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Failed to delete page meta: ${response.statusText}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export class EditorTsPageMetaDurableObject implements DurableObject {
|
|
77
|
+
constructor(private state: DurableObjectState, private env: Env) {}
|
|
78
|
+
|
|
79
|
+
async fetch(request: Request): Promise<Response> {
|
|
80
|
+
const url = new URL(request.url);
|
|
81
|
+
if (url.pathname === '/meta/index' && request.method === 'GET') {
|
|
82
|
+
const list = (await this.state.storage.get<PageMeta[]>('index')) ?? [];
|
|
83
|
+
return new Response(JSON.stringify(list), {
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (url.pathname !== '/meta') {
|
|
89
|
+
return new Response('Not found', { status: 404 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (request.method === 'GET') {
|
|
93
|
+
const meta = await this.state.storage.get<PageMeta>('meta');
|
|
94
|
+
if (!meta) return new Response('Not found', { status: 404 });
|
|
95
|
+
return new Response(JSON.stringify(meta), {
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (request.method === 'POST') {
|
|
101
|
+
const meta = (await request.json()) as PageMeta;
|
|
102
|
+
await this.state.storage.put('meta', meta);
|
|
103
|
+
|
|
104
|
+
const indexId = this.env.EDITOR_TS_META.idFromName('index');
|
|
105
|
+
const indexStub = this.env.EDITOR_TS_META.get(indexId);
|
|
106
|
+
await indexStub.fetch('https://editor-ts.local/meta/index', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(meta),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return new Response('ok');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (request.method === 'DELETE') {
|
|
116
|
+
await this.state.storage.delete('meta');
|
|
117
|
+
|
|
118
|
+
const indexId = this.env.EDITOR_TS_META.idFromName('index');
|
|
119
|
+
const indexStub = this.env.EDITOR_TS_META.get(indexId);
|
|
120
|
+
await indexStub.fetch('https://editor-ts.local/meta/index', {
|
|
121
|
+
method: 'DELETE',
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({ key: this.state.id.toString() }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return new Response('ok');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Response('Method not allowed', { status: 405 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class EditorTsPageMetaIndexDurableObject implements DurableObject {
|
|
134
|
+
constructor(private state: DurableObjectState) {}
|
|
135
|
+
|
|
136
|
+
async fetch(request: Request): Promise<Response> {
|
|
137
|
+
const url = new URL(request.url);
|
|
138
|
+
if (url.pathname !== '/meta/index') {
|
|
139
|
+
return new Response('Not found', { status: 404 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (request.method === 'GET') {
|
|
143
|
+
const list = (await this.state.storage.get<PageMeta[]>('index')) ?? [];
|
|
144
|
+
return new Response(JSON.stringify(list), {
|
|
145
|
+
headers: { 'content-type': 'application/json' },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (request.method === 'POST') {
|
|
150
|
+
const meta = (await request.json()) as PageMeta;
|
|
151
|
+
const list = (await this.state.storage.get<PageMeta[]>('index')) ?? [];
|
|
152
|
+
const next = list.filter((entry: PageMeta) => entry.key !== meta.key);
|
|
153
|
+
next.push(meta);
|
|
154
|
+
next.sort((a: PageMeta, b: PageMeta) => b.updatedAt.localeCompare(a.updatedAt));
|
|
155
|
+
await this.state.storage.put('index', next);
|
|
156
|
+
return new Response('ok');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (request.method === 'DELETE') {
|
|
160
|
+
const payload = (await request.json()) as { key?: string };
|
|
161
|
+
const key = payload.key ?? '';
|
|
162
|
+
const list = (await this.state.storage.get<PageMeta[]>('index')) ?? [];
|
|
163
|
+
const next = list.filter((entry: PageMeta) => entry.key !== key);
|
|
164
|
+
await this.state.storage.put('index', next);
|
|
165
|
+
return new Response('ok');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new Response('Method not allowed', { status: 405 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface Env {
|
|
173
|
+
EDITOR_TS_META: DurableObjectNamespace;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type { DurableObjectNamespace, DurableObject, DurableObjectState, DurableObjectStorage, DurableObjectId, DurableObjectStub };
|
|
177
|
+
|
|
178
|
+
interface CloudflareWebSocketPair {
|
|
179
|
+
0: WebSocket;
|
|
180
|
+
1: WebSocket;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type CfSyncEnv = Record<string, string | undefined>;
|
|
184
|
+
|
|
185
|
+
export const createCfSyncWorker = (options?: {
|
|
186
|
+
onSync?: (message: EditorTsSyncMessage) => Promise<void> | void;
|
|
187
|
+
}) => {
|
|
188
|
+
const onSync = options?.onSync;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
fetch(request: Request, _env: CfSyncEnv): Response {
|
|
192
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
193
|
+
return new Response('Upgrade required', { status: 426 });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pair = new (((globalThis as unknown) as { WebSocketPair: { new(): CloudflareWebSocketPair } }).WebSocketPair)();
|
|
197
|
+
const client = pair[0];
|
|
198
|
+
const server = pair[1];
|
|
199
|
+
|
|
200
|
+
const serverSocket = server as WebSocket & { accept?: () => void };
|
|
201
|
+
serverSocket.accept?.();
|
|
202
|
+
server.send(JSON.stringify(createSyncAck('connected')));
|
|
203
|
+
|
|
204
|
+
server.addEventListener('message', async (event) => {
|
|
205
|
+
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
|
206
|
+
const envelope = parseSyncEnvelope(data);
|
|
207
|
+
if (!envelope || !isSyncMessage(envelope)) {
|
|
208
|
+
server.send(JSON.stringify(createSyncAck('invalid-payload')));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (onSync) {
|
|
213
|
+
await onSync(envelope);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
server.send(JSON.stringify(createSyncAck(envelope.key ?? 'page')));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return new Response(null, {
|
|
220
|
+
status: 101,
|
|
221
|
+
webSocket: client,
|
|
222
|
+
} as ResponseInit);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
export const pages = sqliteTable('pages', {
|
|
4
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
5
|
+
key: text('key').notNull().unique(),
|
|
6
|
+
title: text('title').notNull(),
|
|
7
|
+
itemId: integer('item_id').notNull(),
|
|
8
|
+
body: text('body').notNull(),
|
|
9
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
10
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const pageFiles = sqliteTable('page_files', {
|
|
14
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
15
|
+
pageId: integer('page_id')
|
|
16
|
+
.notNull()
|
|
17
|
+
.references(() => pages.id, { onDelete: 'cascade' }),
|
|
18
|
+
path: text('path').notNull(),
|
|
19
|
+
content: text('content').notNull(),
|
|
20
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
|
21
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { StorageAdapter } from '../core/StorageManager';
|
|
2
|
+
import type { PageData, EditorTsSyncAck, EditorTsSyncEnvelope, EditorTsSyncMessage, PagePayload } from '../types';
|
|
3
|
+
|
|
4
|
+
export type ServerPageMeta = {
|
|
5
|
+
key: string;
|
|
6
|
+
updatedAt: number;
|
|
7
|
+
checksum?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ServerFile = {
|
|
11
|
+
path: string;
|
|
12
|
+
content: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ServerSyncAdapter = {
|
|
16
|
+
listPages(): Promise<ServerPageMeta[]>;
|
|
17
|
+
listFiles(pageKey: string): Promise<ServerFile[]>;
|
|
18
|
+
saveFiles(pageKey: string, files: ServerFile[]): Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type FrontendSyncOptions = {
|
|
22
|
+
pageKey: string;
|
|
23
|
+
storage: StorageAdapter;
|
|
24
|
+
adapter: ServerSyncAdapter;
|
|
25
|
+
includeFiles?: (path: string) => boolean;
|
|
26
|
+
onStatus?: (status: FrontendSyncStatus) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type FrontendSyncStatus =
|
|
30
|
+
| { state: 'loading' }
|
|
31
|
+
| { state: 'saving' }
|
|
32
|
+
| { state: 'idle' }
|
|
33
|
+
| { state: 'error'; message: string };
|
|
34
|
+
|
|
35
|
+
export interface PageMeta {
|
|
36
|
+
key: string;
|
|
37
|
+
title: string;
|
|
38
|
+
itemId: number;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PageMetaStore {
|
|
43
|
+
save(meta: PageMeta): Promise<void>;
|
|
44
|
+
get(key: string): Promise<PageMeta | null>;
|
|
45
|
+
list(): Promise<PageMeta[]>;
|
|
46
|
+
delete(key: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const defaultIncludeFiles = (path: string): boolean => {
|
|
50
|
+
return path === 'page.json' || path === 'styles.css' || path === 'index.html' || path.startsWith('components/');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const safeParseJson = <T>(raw: string | null): T | null => {
|
|
54
|
+
if (!raw) return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(raw) as T;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const encodeStorageKey = (pageKey: string, path: string): string => {
|
|
63
|
+
return `${pageKey}:${path}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const decodeStorageKey = (pageKey: string, storageKey: string): string => {
|
|
67
|
+
return storageKey.replace(`${pageKey}:`, '');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const computeChecksum = (content: string): string => {
|
|
71
|
+
let hash = 2166136261;
|
|
72
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
73
|
+
hash ^= content.charCodeAt(i);
|
|
74
|
+
hash = Math.imul(hash, 16777619);
|
|
75
|
+
}
|
|
76
|
+
return (hash >>> 0).toString(16);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const updateStatus = (options: FrontendSyncOptions, status: FrontendSyncStatus) => {
|
|
80
|
+
options.onStatus?.(status);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const syncFrontendWithServer = async (options: FrontendSyncOptions): Promise<void> => {
|
|
84
|
+
const { pageKey, storage, adapter } = options;
|
|
85
|
+
const includeFiles = options.includeFiles ?? defaultIncludeFiles;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
updateStatus(options, { state: 'loading' });
|
|
89
|
+
|
|
90
|
+
const remotePages = await adapter.listPages();
|
|
91
|
+
const remoteMeta = remotePages.find((page) => page.key === pageKey);
|
|
92
|
+
|
|
93
|
+
const localMetaKey = encodeStorageKey(pageKey, 'meta');
|
|
94
|
+
const localMeta = safeParseJson<ServerPageMeta>(await storage.loadPage(localMetaKey));
|
|
95
|
+
|
|
96
|
+
if (remoteMeta && (!localMeta || remoteMeta.updatedAt > localMeta.updatedAt)) {
|
|
97
|
+
const remoteFiles = await adapter.listFiles(pageKey);
|
|
98
|
+
const filtered = remoteFiles.filter((file) => includeFiles(file.path));
|
|
99
|
+
|
|
100
|
+
await Promise.all(
|
|
101
|
+
filtered.map(async (file) => {
|
|
102
|
+
await storage.savePage(encodeStorageKey(pageKey, file.path), file.content);
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await storage.savePage(localMetaKey, JSON.stringify(remoteMeta));
|
|
107
|
+
updateStatus(options, { state: 'idle' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
updateStatus(options, { state: 'saving' });
|
|
112
|
+
|
|
113
|
+
const storedKeys = await storage.listPages();
|
|
114
|
+
const pageKeys = storedKeys.filter((key) => key.startsWith(`${pageKey}:`));
|
|
115
|
+
|
|
116
|
+
const fileKeys = pageKeys.filter((key) => {
|
|
117
|
+
const path = decodeStorageKey(pageKey, key);
|
|
118
|
+
return path !== 'meta' && includeFiles(path);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const files = await Promise.all(
|
|
122
|
+
fileKeys.map(async (key) => {
|
|
123
|
+
const content = (await storage.loadPage(key)) ?? '';
|
|
124
|
+
return {
|
|
125
|
+
path: decodeStorageKey(pageKey, key),
|
|
126
|
+
content,
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await adapter.saveFiles(pageKey, files);
|
|
132
|
+
|
|
133
|
+
const payload: ServerPageMeta = {
|
|
134
|
+
key: pageKey,
|
|
135
|
+
updatedAt: Date.now(),
|
|
136
|
+
checksum: computeChecksum(files.map((file) => file.content).join('|')),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await storage.savePage(localMetaKey, JSON.stringify(payload));
|
|
140
|
+
updateStatus(options, { state: 'idle' });
|
|
141
|
+
} catch (err: unknown) {
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
updateStatus(options, { state: 'error', message });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const createPageMeta = (
|
|
148
|
+
key: string,
|
|
149
|
+
page: PageData,
|
|
150
|
+
options?: { updatedAt?: string }
|
|
151
|
+
): PageMeta => {
|
|
152
|
+
return {
|
|
153
|
+
key,
|
|
154
|
+
title: page.title,
|
|
155
|
+
itemId: page.item_id,
|
|
156
|
+
updatedAt: options?.updatedAt ?? new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const isSyncMessage = (message: EditorTsSyncEnvelope): message is EditorTsSyncMessage => {
|
|
161
|
+
return message.type === 'page';
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const isSyncAck = (message: EditorTsSyncEnvelope): message is EditorTsSyncAck => {
|
|
165
|
+
return message.type === 'ack';
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const createSyncMessage = (payload: PagePayload, options?: { key?: string }): EditorTsSyncMessage => {
|
|
169
|
+
return {
|
|
170
|
+
type: 'page',
|
|
171
|
+
payload,
|
|
172
|
+
key: options?.key,
|
|
173
|
+
sentAt: new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const createSyncAck = (messageId: string): EditorTsSyncAck => {
|
|
178
|
+
return {
|
|
179
|
+
type: 'ack',
|
|
180
|
+
messageId,
|
|
181
|
+
receivedAt: new Date().toISOString(),
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const parseSyncEnvelope = (raw: string): EditorTsSyncEnvelope | null => {
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(raw) as EditorTsSyncEnvelope;
|
|
188
|
+
if (parsed && (parsed.type === 'page' || parsed.type === 'ack')) {
|
|
189
|
+
return parsed;
|
|
190
|
+
}
|
|
191
|
+
} catch (error: unknown) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
};
|