@graview/ship 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 +96 -0
- package/README.md +95 -0
- package/dist/browser-adapter.d.ts +84 -0
- package/dist/browser-adapter.d.ts.map +1 -0
- package/dist/browser-adapter.js +111 -0
- package/dist/browser-adapter.js.map +1 -0
- package/dist/browser.d.ts +26 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +16 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +170 -0
- package/dist/cli.js.map +1 -0
- package/dist/dev.d.ts +148 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +286 -0
- package/dist/dev.js.map +1 -0
- package/dist/door.d.ts +22 -0
- package/dist/door.d.ts.map +1 -0
- package/dist/door.js +43 -0
- package/dist/door.js.map +1 -0
- package/dist/export.d.ts +25 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +30 -0
- package/dist/export.js.map +1 -0
- package/dist/file-adapter.d.ts +28 -0
- package/dist/file-adapter.d.ts.map +1 -0
- package/dist/file-adapter.js +50 -0
- package/dist/file-adapter.js.map +1 -0
- package/dist/health.d.ts +19 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +16 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +31 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +59 -0
- package/dist/migrations.js.map +1 -0
- package/dist/open-store.d.ts +57 -0
- package/dist/open-store.d.ts.map +1 -0
- package/dist/open-store.js +103 -0
- package/dist/open-store.js.map +1 -0
- package/dist/photos.d.ts +89 -0
- package/dist/photos.d.ts.map +1 -0
- package/dist/photos.js +102 -0
- package/dist/photos.js.map +1 -0
- package/dist/presence.d.ts +33 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +99 -0
- package/dist/presence.js.map +1 -0
- package/dist/remote.d.ts +73 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +271 -0
- package/dist/remote.js.map +1 -0
- package/dist/serve.d.ts +121 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +249 -0
- package/dist/serve.js.map +1 -0
- package/dist/snapshot.d.ts +20 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +41 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/source-edit.d.ts +52 -0
- package/dist/source-edit.d.ts.map +1 -0
- package/dist/source-edit.js +455 -0
- package/dist/source-edit.js.map +1 -0
- package/dist/steps.d.ts +98 -0
- package/dist/steps.d.ts.map +1 -0
- package/dist/steps.js +189 -0
- package/dist/steps.js.map +1 -0
- package/dist/studio-door.d.ts +36 -0
- package/dist/studio-door.d.ts.map +1 -0
- package/dist/studio-door.js +102 -0
- package/dist/studio-door.js.map +1 -0
- package/dist/sync-seed.d.ts +61 -0
- package/dist/sync-seed.d.ts.map +1 -0
- package/dist/sync-seed.js +95 -0
- package/dist/sync-seed.js.map +1 -0
- package/dist/typecheck.d.ts +15 -0
- package/dist/typecheck.d.ts.map +1 -0
- package/dist/typecheck.js +59 -0
- package/dist/typecheck.js.map +1 -0
- package/package.json +75 -0
package/dist/remote.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { foldPresence, PRESENCE_TTL_MS, samePresence, Store, } from "@graview/core";
|
|
2
|
+
/**
|
|
3
|
+
* Opens a store from a server and keeps it in step with it.
|
|
4
|
+
*
|
|
5
|
+
* `seed` is deliberately absent: what is on the server is what there is. A
|
|
6
|
+
* client that seeded would be a client that could overwrite everybody's
|
|
7
|
+
* roster by being the first to load.
|
|
8
|
+
*/
|
|
9
|
+
/** A counter nobody else is using: this client's own, provisional ids. */
|
|
10
|
+
function localIds() {
|
|
11
|
+
const who = Math.random().toString(36).slice(2, 8);
|
|
12
|
+
let at = 0;
|
|
13
|
+
return () => `local-${who}-${++at}`;
|
|
14
|
+
}
|
|
15
|
+
export async function openRemote(options) {
|
|
16
|
+
const call = options.fetch ?? fetch;
|
|
17
|
+
const headers = { "content-type": "application/json", ...(options.headers ?? {}) };
|
|
18
|
+
if (options.principal?.id)
|
|
19
|
+
headers["x-graview-seat"] = options.principal.id;
|
|
20
|
+
if (options.principal?.roles?.length)
|
|
21
|
+
headers["x-graview-roles"] = options.principal.roles.join(",");
|
|
22
|
+
const state = (await (await call(`${options.url}/graview/state`)).json());
|
|
23
|
+
const store = new Store({
|
|
24
|
+
schema: options.app.schema,
|
|
25
|
+
mutations: options.app.mutations ?? [],
|
|
26
|
+
invariants: options.app.invariants ?? [],
|
|
27
|
+
...(options.app.policy ? { policy: options.app.policy } : {}),
|
|
28
|
+
...(options.app.modules ? { modules: options.app.modules } : {}),
|
|
29
|
+
...(options.app.intelligence ? { intelligence: options.app.intelligence } : {}),
|
|
30
|
+
/*
|
|
31
|
+
* The snapshot AND the log: the snapshot is the graph, the log is the
|
|
32
|
+
* history that led to it. Folding the log alone would lose whatever the
|
|
33
|
+
* server was seeded with, which was never an operation.
|
|
34
|
+
*/
|
|
35
|
+
snapshot: state.snapshot,
|
|
36
|
+
log: state.log,
|
|
37
|
+
/*
|
|
38
|
+
* IDS THIS CLIENT CANNOT SHARE WITH ANYBODY.
|
|
39
|
+
*
|
|
40
|
+
* A store's default id is a monotonic counter, which is exactly right
|
|
41
|
+
* for a test and exactly wrong for a second writer: two stores both
|
|
42
|
+
* start at one, so this browser's first optimistic op and the server's
|
|
43
|
+
* first op are both `op1` — and `receive` then drops the server's as
|
|
44
|
+
* one it already had. The change silently never arrived, on whichever
|
|
45
|
+
* browser happened to have acted once.
|
|
46
|
+
*
|
|
47
|
+
* The ops this store mints are PROVISIONAL anyway: the server's own is
|
|
48
|
+
* what stays. Prefixing them says so, and makes the collision
|
|
49
|
+
* impossible rather than unlikely.
|
|
50
|
+
*/
|
|
51
|
+
ids: localIds(),
|
|
52
|
+
...(options.storeOptions ?? {}),
|
|
53
|
+
});
|
|
54
|
+
let seen = state.log.at(-1)?.seq ?? -1;
|
|
55
|
+
/*
|
|
56
|
+
* PRESENCE, beside the log and never in it. `mine` is the last word this
|
|
57
|
+
* browser said about itself; it goes out with every poll while it stands,
|
|
58
|
+
* and what comes back is folded into `known` for whoever is listening.
|
|
59
|
+
*/
|
|
60
|
+
let mine = null;
|
|
61
|
+
let known = new Map();
|
|
62
|
+
const whoListeners = new Set();
|
|
63
|
+
const heard = (who) => {
|
|
64
|
+
const next = foldPresence(new Map(), who, Date.now(), PRESENCE_TTL_MS * 4, mine?.participant);
|
|
65
|
+
let changed = next.size !== known.size;
|
|
66
|
+
if (!changed)
|
|
67
|
+
for (const [participant, presence] of next)
|
|
68
|
+
if (!samePresence(presence, known.get(participant)))
|
|
69
|
+
changed = true;
|
|
70
|
+
known = next;
|
|
71
|
+
if (!changed)
|
|
72
|
+
return;
|
|
73
|
+
for (const listener of whoListeners)
|
|
74
|
+
listener([...known.values()]);
|
|
75
|
+
};
|
|
76
|
+
const land = (ops, applied = false) => {
|
|
77
|
+
if (ops.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
seen = Math.max(seen, ...ops.map((op) => op.seq));
|
|
80
|
+
return store.receive(ops, { applied });
|
|
81
|
+
};
|
|
82
|
+
const pull = async () => {
|
|
83
|
+
if (mine) {
|
|
84
|
+
const response = await call(`${options.url}/graview/here`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers,
|
|
87
|
+
body: JSON.stringify({ presence: mine, seq: seen }),
|
|
88
|
+
});
|
|
89
|
+
const { who, ops } = (await response.json());
|
|
90
|
+
heard(who ?? []);
|
|
91
|
+
return land(ops ?? []);
|
|
92
|
+
}
|
|
93
|
+
const response = await call(`${options.url}/graview/since?seq=${seen}`, { headers });
|
|
94
|
+
const { ops } = (await response.json());
|
|
95
|
+
return land(ops);
|
|
96
|
+
};
|
|
97
|
+
const presence = {
|
|
98
|
+
here(next) {
|
|
99
|
+
mine = next;
|
|
100
|
+
},
|
|
101
|
+
onWho(listener) {
|
|
102
|
+
whoListeners.add(listener);
|
|
103
|
+
return () => {
|
|
104
|
+
whoListeners.delete(listener);
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
leave() {
|
|
108
|
+
if (!mine)
|
|
109
|
+
return;
|
|
110
|
+
const body = JSON.stringify({ participant: mine.participant });
|
|
111
|
+
mine = null;
|
|
112
|
+
// A page on its way out gets one shot; a beacon is what survives it.
|
|
113
|
+
const beacon = globalThis.navigator?.sendBeacon;
|
|
114
|
+
if (beacon)
|
|
115
|
+
beacon.call(globalThis.navigator, `${options.url}/graview/leave`, body);
|
|
116
|
+
else
|
|
117
|
+
void call(`${options.url}/graview/leave`, { method: "POST", headers, body }).catch(() => { });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
/** The server's batch for each provisional one this browser minted, so an undo names what the server has. */
|
|
121
|
+
const batches = new Map();
|
|
122
|
+
/** Every post not yet answered, so `settled` can wait for the verdicts. */
|
|
123
|
+
const inFlight = new Set();
|
|
124
|
+
const track = (promise) => {
|
|
125
|
+
inFlight.add(promise);
|
|
126
|
+
void promise.finally(() => inFlight.delete(promise)).catch(() => { });
|
|
127
|
+
return promise;
|
|
128
|
+
};
|
|
129
|
+
/*
|
|
130
|
+
* `applied` says the local store already holds this change — it was
|
|
131
|
+
* applied optimistically before the post — so the server's op is
|
|
132
|
+
* recorded rather than re-applied. `send` never applies first, so its
|
|
133
|
+
* answer lands in full.
|
|
134
|
+
*/
|
|
135
|
+
const post = async (body, applied = false) => {
|
|
136
|
+
const response = await call(`${options.url}/graview/ops`, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
const answer = (await response.json());
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
// The policy's own sentence, carried across the wire unchanged: a
|
|
144
|
+
// refusal a person can read is the whole point of having one.
|
|
145
|
+
throw new Error(answer.error ?? `The server refused (${response.status})`);
|
|
146
|
+
}
|
|
147
|
+
const ops = answer.ops ?? [];
|
|
148
|
+
land(ops, applied);
|
|
149
|
+
return { ops, ...(answer.batch ? { batch: answer.batch } : {}) };
|
|
150
|
+
};
|
|
151
|
+
const send = async (calls, sending = {}) => (await post({ calls, ...sending })).ops;
|
|
152
|
+
/*
|
|
153
|
+
* APPLIED HERE FIRST, DECIDED THERE.
|
|
154
|
+
*
|
|
155
|
+
* `store.apply` is synchronous — the scene, the strip and the routed face
|
|
156
|
+
* all expect the graph to have moved by the time the press returns — and
|
|
157
|
+
* the network is not. So the local store applies optimistically and the
|
|
158
|
+
* server is told; what comes back is the SERVER's op, which is the one
|
|
159
|
+
* that stays, with the id, author and sequence everybody else will see.
|
|
160
|
+
*
|
|
161
|
+
* And a refusal takes the optimism back. The alternative — leaving the
|
|
162
|
+
* hopeful change on screen and logging the refusal to a console — is the
|
|
163
|
+
* one behaviour that would make this whole design a lie: the interface
|
|
164
|
+
* would be showing a graph the server does not have.
|
|
165
|
+
*/
|
|
166
|
+
const appliedAll = store.applyAll.bind(store);
|
|
167
|
+
const undone = store.undo.bind(store);
|
|
168
|
+
const refusals = new Set();
|
|
169
|
+
/*
|
|
170
|
+
* A refusal takes the optimism back — undone by its own batch, which is
|
|
171
|
+
* the same mechanism a person's undo uses — AS THE SAME PERSON. What you
|
|
172
|
+
* may undo is what you may have done, and an undo judged as nobody is
|
|
173
|
+
* refused in an app with a policy: taking back your own optimism would
|
|
174
|
+
* have thrown a second, more confusing refusal on top of the first.
|
|
175
|
+
*/
|
|
176
|
+
const takeBack = (batch, error) => {
|
|
177
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
178
|
+
try {
|
|
179
|
+
const asMe = options.principal ? { author: options.principal } : {};
|
|
180
|
+
if (store.canUndo(batch).ok)
|
|
181
|
+
undone(batch, asMe);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// A take-back that cannot run leaves the interface wrong, and
|
|
185
|
+
// saying so is still better than saying nothing.
|
|
186
|
+
}
|
|
187
|
+
for (const told of refusals)
|
|
188
|
+
told(reason);
|
|
189
|
+
};
|
|
190
|
+
/*
|
|
191
|
+
* EVERY WAY THE GRAPH CHANGES GOES DOWN THE WIRE. `apply` is one call;
|
|
192
|
+
* `applyAll` is a whole gesture — a seat's plan lands as one batch — and
|
|
193
|
+
* `undo` is a take-back. The first version patched `apply` alone, so a
|
|
194
|
+
* robot's plans and undos stayed in the browser that made them while its
|
|
195
|
+
* single presses travelled: two windows on one roster disagreed about
|
|
196
|
+
* exactly the changes an agent had made.
|
|
197
|
+
*/
|
|
198
|
+
store.applyAll = ((calls, applyOptions) => {
|
|
199
|
+
/*
|
|
200
|
+
* As WHOEVER IS AT THIS KEYBOARD, by default.
|
|
201
|
+
*
|
|
202
|
+
* The local store carries the app's policy, so a call with no author is
|
|
203
|
+
* judged as an anonymous human — who, in an app with a policy, may do
|
|
204
|
+
* nothing. Every surface that threads a principal is unaffected; what
|
|
205
|
+
* this fixes is the one that does not, which would otherwise meet a
|
|
206
|
+
* refusal about a person who is not sitting there.
|
|
207
|
+
*/
|
|
208
|
+
const result = appliedAll(calls, {
|
|
209
|
+
...(options.principal ? { author: options.principal } : {}),
|
|
210
|
+
...applyOptions,
|
|
211
|
+
});
|
|
212
|
+
track(post({ calls, ...(applyOptions?.intent ? { intent: applyOptions.intent } : {}) }, true)
|
|
213
|
+
.then((answer) => {
|
|
214
|
+
if (answer.batch)
|
|
215
|
+
batches.set(result.batch, answer.batch);
|
|
216
|
+
})
|
|
217
|
+
.catch((error) => takeBack(result.batch, error)));
|
|
218
|
+
return result;
|
|
219
|
+
});
|
|
220
|
+
// `apply` is `applyAll` of one — and it must go through the patched one.
|
|
221
|
+
store.apply = ((callMade, applyOptions) => store.applyAll([callMade], applyOptions));
|
|
222
|
+
store.undo = ((batchIds, undoOptions) => {
|
|
223
|
+
const ids = typeof batchIds === "string" ? [batchIds] : batchIds;
|
|
224
|
+
const result = undone(ids, {
|
|
225
|
+
...(options.principal ? { author: options.principal } : {}),
|
|
226
|
+
...undoOptions,
|
|
227
|
+
});
|
|
228
|
+
// Named as the server knows them: a provisional batch by the one it became.
|
|
229
|
+
const theirs = ids.map((id) => batches.get(id) ?? id);
|
|
230
|
+
track(post({ undo: theirs, ...(undoOptions?.intent ? { intent: undoOptions.intent } : {}) }, true)
|
|
231
|
+
.then((answer) => {
|
|
232
|
+
if (answer.batch)
|
|
233
|
+
batches.set(result.batch, answer.batch);
|
|
234
|
+
})
|
|
235
|
+
.catch((error) => takeBack(result.batch, error)));
|
|
236
|
+
return result;
|
|
237
|
+
});
|
|
238
|
+
const every = options.pollMs ?? 800;
|
|
239
|
+
const timer = every > 0 && typeof setInterval === "function"
|
|
240
|
+
? setInterval(() => {
|
|
241
|
+
void pull().catch(() => {
|
|
242
|
+
// A poll that cannot reach the server is a poll that will try
|
|
243
|
+
// again; throwing here would take the interface down with the
|
|
244
|
+
// connection.
|
|
245
|
+
});
|
|
246
|
+
}, every)
|
|
247
|
+
: null;
|
|
248
|
+
return {
|
|
249
|
+
store,
|
|
250
|
+
version: state.version,
|
|
251
|
+
migrated: state.migrated ?? [],
|
|
252
|
+
send,
|
|
253
|
+
pull,
|
|
254
|
+
onRefusal(listener) {
|
|
255
|
+
refusals.add(listener);
|
|
256
|
+
return () => refusals.delete(listener);
|
|
257
|
+
},
|
|
258
|
+
async settled() {
|
|
259
|
+
// Whatever is in flight now — and whatever a settling handler put in flight after it.
|
|
260
|
+
while (inFlight.size > 0)
|
|
261
|
+
await Promise.allSettled([...inFlight]);
|
|
262
|
+
},
|
|
263
|
+
presence,
|
|
264
|
+
close() {
|
|
265
|
+
if (timer)
|
|
266
|
+
clearInterval(timer);
|
|
267
|
+
presence.leave();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=remote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"remote.js","sourceRoot":"","sources":["../src/remote.ts"],"names":[],"mappings":"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"}
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server } from "node:http";
|
|
2
|
+
import { Store, type AnySchema, type GraviewApp, type Operation, type PersistenceAdapter, type Principal } from "@graview/core";
|
|
3
|
+
import { type OpenedStore } from "./open-store.js";
|
|
4
|
+
import type { GraphSnapshot } from "./snapshot.js";
|
|
5
|
+
/**
|
|
6
|
+
* THE STORE, BEHIND HTTP — and the op log is the wire.
|
|
7
|
+
*
|
|
8
|
+
* Two server-side adapters existed and nothing showed either, so the
|
|
9
|
+
* strongest thing this platform can say to somebody self-hosting — "where
|
|
10
|
+
* is my data" answered with a folder you can open — was a capability the
|
|
11
|
+
* launcher asserted rather than a capability anybody had seen.
|
|
12
|
+
*
|
|
13
|
+
* What is deliberately NOT here: a second way to change the graph. A client
|
|
14
|
+
* sends CALLS, not primitives; the server applies them through an ordinary
|
|
15
|
+
* `Store` under the principal the request carries, so the policy judges
|
|
16
|
+
* them exactly as it judges them in a browser, the invariants run, and the
|
|
17
|
+
* op that comes back is the same shape the browser would have made. A
|
|
18
|
+
* client that could post primitives would be a client that could write past
|
|
19
|
+
* every rule the app declares.
|
|
20
|
+
*
|
|
21
|
+
* The whole protocol is four routes:
|
|
22
|
+
*
|
|
23
|
+
* GET /graview/state the graph, the log and the stored version
|
|
24
|
+
* POST /graview/ops calls in, the ops they produced out — or `undo`, batches to take back
|
|
25
|
+
* GET /graview/since?seq=N the ops appended after N — everyone else's
|
|
26
|
+
* GET /graview/health ship's own report, plus where the data is
|
|
27
|
+
*
|
|
28
|
+
* And two for WHO IS HERE, which is not the op log and never touches it:
|
|
29
|
+
*
|
|
30
|
+
* POST /graview/here say where you are; answers with who else is, and the ops since `seq`
|
|
31
|
+
* GET /graview/who who is here right now
|
|
32
|
+
* POST /graview/leave say you have gone
|
|
33
|
+
*
|
|
34
|
+
* Presence lives in a map inside this closure with a time to live — not in
|
|
35
|
+
* the store, not in the adapter, not in the log. A tab that dies without a
|
|
36
|
+
* word is gone after three missed heartbeats.
|
|
37
|
+
*
|
|
38
|
+
* Polling rather than a socket, on purpose: a demo whose point is "your data
|
|
39
|
+
* is in this folder" should not also be a demonstration of connection
|
|
40
|
+
* lifecycles, and a poll is a thing a person can reproduce with `curl`.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* THE WIRE, AS A CONTRACT. Every route `serveStore` answers, in one place a
|
|
44
|
+
* test pins and a README repeats: what a host in front of this — Graview
|
|
45
|
+
* Cloud, or anybody's — must keep answering for `openRemote`, `graview mcp
|
|
46
|
+
* --remote-url` and `graview apply --remote-url` to work unchanged. What a
|
|
47
|
+
* host adds (who a bearer token is, which tenant, how much) goes in
|
|
48
|
+
* `seatOf` and around these routes, never inside them.
|
|
49
|
+
*/
|
|
50
|
+
export declare const WIRE: readonly [{
|
|
51
|
+
readonly method: "GET";
|
|
52
|
+
readonly path: "/graview/state";
|
|
53
|
+
readonly says: "the graph, the log and the stored version";
|
|
54
|
+
}, {
|
|
55
|
+
readonly method: "POST";
|
|
56
|
+
readonly path: "/graview/ops";
|
|
57
|
+
readonly says: "calls in, the ops they produced out — or `undo`, batches to take back; 409 with the policy's sentence when refused";
|
|
58
|
+
}, {
|
|
59
|
+
readonly method: "GET";
|
|
60
|
+
readonly path: "/graview/since";
|
|
61
|
+
readonly says: "the ops appended after ?seq=N — everyone else's";
|
|
62
|
+
}, {
|
|
63
|
+
readonly method: "GET";
|
|
64
|
+
readonly path: "/graview/health";
|
|
65
|
+
readonly says: "ship's own report, plus where the data is";
|
|
66
|
+
}, {
|
|
67
|
+
readonly method: "GET";
|
|
68
|
+
readonly path: "/graview/export";
|
|
69
|
+
readonly says: "the whole store as one bundle, the way out";
|
|
70
|
+
}, {
|
|
71
|
+
readonly method: "POST";
|
|
72
|
+
readonly path: "/graview/here";
|
|
73
|
+
readonly says: "say where you are; answers with who else is, and the ops since `seq`";
|
|
74
|
+
}, {
|
|
75
|
+
readonly method: "GET";
|
|
76
|
+
readonly path: "/graview/who";
|
|
77
|
+
readonly says: "who is here right now";
|
|
78
|
+
}, {
|
|
79
|
+
readonly method: "POST";
|
|
80
|
+
readonly path: "/graview/leave";
|
|
81
|
+
readonly says: "say you have gone";
|
|
82
|
+
}];
|
|
83
|
+
/** The headers a request carries its seat in. A host's `seatOf` may read others; these are what the framework's clients send. */
|
|
84
|
+
export declare const SEAT_HEADERS: {
|
|
85
|
+
readonly seat: "x-graview-seat";
|
|
86
|
+
readonly roles: "x-graview-roles";
|
|
87
|
+
};
|
|
88
|
+
export interface ServeOptions<S extends AnySchema> {
|
|
89
|
+
readonly app: GraviewApp<S>;
|
|
90
|
+
/** Where the graph lives. The file adapter by default — see `createFileAdapter`. */
|
|
91
|
+
readonly adapter: PersistenceAdapter<string>;
|
|
92
|
+
/** What to put in an empty store on first start. */
|
|
93
|
+
readonly seed?: GraphSnapshot;
|
|
94
|
+
readonly scope?: string;
|
|
95
|
+
readonly port?: number;
|
|
96
|
+
/**
|
|
97
|
+
* Who a request is, from its own headers.
|
|
98
|
+
*
|
|
99
|
+
* Not an authentication system — this is a demo server, and pretending
|
|
100
|
+
* otherwise would be worse than saying so. It reads `x-graview-seat` and
|
|
101
|
+
* `x-graview-roles` and hands the result to the store, which is enough to
|
|
102
|
+
* show the thing worth showing: the SAME policy refusing the same act on
|
|
103
|
+
* the server that refuses it in the browser.
|
|
104
|
+
*/
|
|
105
|
+
readonly seatOf?: (request: IncomingMessage) => Principal;
|
|
106
|
+
/** Where the data is, for the health report to say out loud. */
|
|
107
|
+
readonly where?: string;
|
|
108
|
+
/** How long a presence stands after its last word. Three heartbeats by default. */
|
|
109
|
+
readonly presenceTtlMs?: number;
|
|
110
|
+
}
|
|
111
|
+
export interface ServedStore<S extends AnySchema> {
|
|
112
|
+
readonly store: Store<S>;
|
|
113
|
+
readonly opened: OpenedStore<S>;
|
|
114
|
+
readonly server: Server;
|
|
115
|
+
readonly port: number;
|
|
116
|
+
readonly url: string;
|
|
117
|
+
close(): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
export declare function serveStore<S extends AnySchema>(options: ServeOptions<S>): Promise<ServedStore<S>>;
|
|
120
|
+
export type { Operation };
|
|
121
|
+
//# sourceMappingURL=serve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AACjG,OAAO,EAGL,KAAK,EACL,KAAK,SAAS,EACd,KAAK,UAAU,EAEf,KAAK,SAAS,EACd,KAAK,kBAAkB,EAEvB,KAAK,SAAS,EACf,MAAM,eAAe,CAAC;AAGvB,OAAO,EAAa,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASP,CAAC;AAEX,iIAAiI;AACjI,eAAO,MAAM,YAAY;;;CAAgE,CAAC;AAE1F,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,SAAS;IAC/C,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC;IAC5B,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC,MAAM,CAAC,CAAC;IAC7C,oDAAoD;IACpD,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,SAAS,CAAC;IAC1D,gEAAgE;IAChE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,mFAAmF;IACnF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,SAAS;IAC9C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiBD,wBAAsB,UAAU,CAAC,CAAC,SAAS,SAAS,EAAE,OAAO,EAAE,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAyKvG;AAyBD,YAAY,EAAE,SAAS,EAAE,CAAC"}
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { foldPresence, PRESENCE_TTL_MS, Store, } from "@graview/core";
|
|
3
|
+
import { exportBundle } from "./export.js";
|
|
4
|
+
import { health } from "./health.js";
|
|
5
|
+
import { openStore } from "./open-store.js";
|
|
6
|
+
/**
|
|
7
|
+
* THE STORE, BEHIND HTTP — and the op log is the wire.
|
|
8
|
+
*
|
|
9
|
+
* Two server-side adapters existed and nothing showed either, so the
|
|
10
|
+
* strongest thing this platform can say to somebody self-hosting — "where
|
|
11
|
+
* is my data" answered with a folder you can open — was a capability the
|
|
12
|
+
* launcher asserted rather than a capability anybody had seen.
|
|
13
|
+
*
|
|
14
|
+
* What is deliberately NOT here: a second way to change the graph. A client
|
|
15
|
+
* sends CALLS, not primitives; the server applies them through an ordinary
|
|
16
|
+
* `Store` under the principal the request carries, so the policy judges
|
|
17
|
+
* them exactly as it judges them in a browser, the invariants run, and the
|
|
18
|
+
* op that comes back is the same shape the browser would have made. A
|
|
19
|
+
* client that could post primitives would be a client that could write past
|
|
20
|
+
* every rule the app declares.
|
|
21
|
+
*
|
|
22
|
+
* The whole protocol is four routes:
|
|
23
|
+
*
|
|
24
|
+
* GET /graview/state the graph, the log and the stored version
|
|
25
|
+
* POST /graview/ops calls in, the ops they produced out — or `undo`, batches to take back
|
|
26
|
+
* GET /graview/since?seq=N the ops appended after N — everyone else's
|
|
27
|
+
* GET /graview/health ship's own report, plus where the data is
|
|
28
|
+
*
|
|
29
|
+
* And two for WHO IS HERE, which is not the op log and never touches it:
|
|
30
|
+
*
|
|
31
|
+
* POST /graview/here say where you are; answers with who else is, and the ops since `seq`
|
|
32
|
+
* GET /graview/who who is here right now
|
|
33
|
+
* POST /graview/leave say you have gone
|
|
34
|
+
*
|
|
35
|
+
* Presence lives in a map inside this closure with a time to live — not in
|
|
36
|
+
* the store, not in the adapter, not in the log. A tab that dies without a
|
|
37
|
+
* word is gone after three missed heartbeats.
|
|
38
|
+
*
|
|
39
|
+
* Polling rather than a socket, on purpose: a demo whose point is "your data
|
|
40
|
+
* is in this folder" should not also be a demonstration of connection
|
|
41
|
+
* lifecycles, and a poll is a thing a person can reproduce with `curl`.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* THE WIRE, AS A CONTRACT. Every route `serveStore` answers, in one place a
|
|
45
|
+
* test pins and a README repeats: what a host in front of this — Graview
|
|
46
|
+
* Cloud, or anybody's — must keep answering for `openRemote`, `graview mcp
|
|
47
|
+
* --remote-url` and `graview apply --remote-url` to work unchanged. What a
|
|
48
|
+
* host adds (who a bearer token is, which tenant, how much) goes in
|
|
49
|
+
* `seatOf` and around these routes, never inside them.
|
|
50
|
+
*/
|
|
51
|
+
export const WIRE = [
|
|
52
|
+
{ method: "GET", path: "/graview/state", says: "the graph, the log and the stored version" },
|
|
53
|
+
{ method: "POST", path: "/graview/ops", says: "calls in, the ops they produced out — or `undo`, batches to take back; 409 with the policy's sentence when refused" },
|
|
54
|
+
{ method: "GET", path: "/graview/since", says: "the ops appended after ?seq=N — everyone else's" },
|
|
55
|
+
{ method: "GET", path: "/graview/health", says: "ship's own report, plus where the data is" },
|
|
56
|
+
{ method: "GET", path: "/graview/export", says: "the whole store as one bundle, the way out" },
|
|
57
|
+
{ method: "POST", path: "/graview/here", says: "say where you are; answers with who else is, and the ops since `seq`" },
|
|
58
|
+
{ method: "GET", path: "/graview/who", says: "who is here right now" },
|
|
59
|
+
{ method: "POST", path: "/graview/leave", says: "say you have gone" },
|
|
60
|
+
];
|
|
61
|
+
/** The headers a request carries its seat in. A host's `seatOf` may read others; these are what the framework's clients send. */
|
|
62
|
+
export const SEAT_HEADERS = { seat: "x-graview-seat", roles: "x-graview-roles" };
|
|
63
|
+
const SEAT = (request) => {
|
|
64
|
+
const id = header(request, "x-graview-seat");
|
|
65
|
+
const roles = header(request, "x-graview-roles");
|
|
66
|
+
return {
|
|
67
|
+
kind: "human",
|
|
68
|
+
...(id ? { id } : {}),
|
|
69
|
+
...(roles ? { roles: roles.split(",").filter(Boolean) } : {}),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
function header(request, name) {
|
|
73
|
+
const value = request.headers[name];
|
|
74
|
+
return Array.isArray(value) ? value[0] : value;
|
|
75
|
+
}
|
|
76
|
+
export async function serveStore(options) {
|
|
77
|
+
const scope = options.scope ?? options.app.name;
|
|
78
|
+
const seatOf = options.seatOf ?? SEAT;
|
|
79
|
+
/*
|
|
80
|
+
* The migration runs HERE, once, against the stored graph — which is the
|
|
81
|
+
* whole reason a server is the honest place for it. A browser that
|
|
82
|
+
* migrates its own copy migrates it once per browser; a server that
|
|
83
|
+
* migrates the roster migrates the roster.
|
|
84
|
+
*/
|
|
85
|
+
const opened = await openStore({
|
|
86
|
+
app: options.app,
|
|
87
|
+
adapter: options.adapter,
|
|
88
|
+
scope,
|
|
89
|
+
...(options.seed ? { seed: options.seed } : {}),
|
|
90
|
+
});
|
|
91
|
+
const store = opened.store;
|
|
92
|
+
/*
|
|
93
|
+
* WHO IS HERE. Keyed by participant, named by the seat the request
|
|
94
|
+
* carries — a poster cannot claim to be somebody else's seat — and
|
|
95
|
+
* stamped with the server's clock, so expiry does not depend on two
|
|
96
|
+
* browsers agreeing what time it is.
|
|
97
|
+
*/
|
|
98
|
+
const ttl = options.presenceTtlMs ?? PRESENCE_TTL_MS;
|
|
99
|
+
let here = new Map();
|
|
100
|
+
const alive = () => {
|
|
101
|
+
here = foldPresence(here, [], Date.now(), ttl);
|
|
102
|
+
return [...here.values()];
|
|
103
|
+
};
|
|
104
|
+
const arrive = (told, seat) => {
|
|
105
|
+
const session = told.participant.split(":").slice(2).join(":");
|
|
106
|
+
const participant = seat.id ? `${seat.kind}:${seat.id}:${session}` : told.participant;
|
|
107
|
+
const presence = { ...told, participant, at: new Date().toISOString() };
|
|
108
|
+
here = foldPresence(here, [presence], Date.now(), ttl);
|
|
109
|
+
return presence;
|
|
110
|
+
};
|
|
111
|
+
const since = (seq) => store.log.all().filter((op) => op.seq > seq);
|
|
112
|
+
const server = createServer((request, response) => {
|
|
113
|
+
void handle(request, response).catch((error) => {
|
|
114
|
+
send(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
async function handle(request, response) {
|
|
118
|
+
// A browser on another origin is the ordinary case for an embed.
|
|
119
|
+
response.setHeader("access-control-allow-origin", "*");
|
|
120
|
+
response.setHeader("access-control-allow-headers", "content-type, authorization, x-graview-seat, x-graview-roles");
|
|
121
|
+
response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
122
|
+
if (request.method === "OPTIONS") {
|
|
123
|
+
response.writeHead(204).end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
127
|
+
if (url.pathname === "/graview/state") {
|
|
128
|
+
send(response, 200, {
|
|
129
|
+
version: options.app.version ?? 1,
|
|
130
|
+
snapshot: store.snapshot(),
|
|
131
|
+
log: store.log.all(),
|
|
132
|
+
migrated: opened.migrated.map((op) => op.intent),
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (url.pathname === "/graview/since") {
|
|
137
|
+
const seq = Number(url.searchParams.get("seq") ?? "-1");
|
|
138
|
+
send(response, 200, { ops: since(seq) });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (url.pathname === "/graview/who") {
|
|
142
|
+
send(response, 200, { who: alive() });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (url.pathname === "/graview/here" && request.method === "POST") {
|
|
146
|
+
const body = (await read(request));
|
|
147
|
+
if (!body.presence || typeof body.presence.participant !== "string" || typeof body.presence.stop !== "string") {
|
|
148
|
+
send(response, 400, { error: "A presence is a participant and a stop." });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const mine = arrive(body.presence, seatOf(request));
|
|
152
|
+
// Folded into the poll: the heartbeat carries back everybody else AND
|
|
153
|
+
// the ops since, so being here costs no round trip of its own.
|
|
154
|
+
send(response, 200, {
|
|
155
|
+
who: alive().filter((presence) => presence.participant !== mine.participant),
|
|
156
|
+
...(typeof body.seq === "number" ? { ops: since(body.seq) } : {}),
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (url.pathname === "/graview/leave" && request.method === "POST") {
|
|
161
|
+
const body = (await read(request));
|
|
162
|
+
if (typeof body.participant === "string") {
|
|
163
|
+
here = new Map(here);
|
|
164
|
+
here.delete(body.participant);
|
|
165
|
+
}
|
|
166
|
+
send(response, 200, { who: alive() });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (url.pathname === "/graview/health") {
|
|
170
|
+
send(response, 200, {
|
|
171
|
+
...health(store, options.app),
|
|
172
|
+
// Said by the adapter where it knows, because it is the only thing
|
|
173
|
+
// that does; `where` is for a caller with a better name for it.
|
|
174
|
+
where: options.where ?? options.adapter.root ?? scope,
|
|
175
|
+
adapter: options.adapter.name,
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (url.pathname === "/graview/export") {
|
|
180
|
+
send(response, 200, exportBundle(store, options.app));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (url.pathname === "/graview/ops" && request.method === "POST") {
|
|
184
|
+
const body = (await read(request));
|
|
185
|
+
const calls = body.calls ?? [];
|
|
186
|
+
const seat = seatOf(request);
|
|
187
|
+
try {
|
|
188
|
+
/*
|
|
189
|
+
* Through the STORE, under the requester's own seat. The policy
|
|
190
|
+
* refuses here exactly what it refuses in the browser — and the
|
|
191
|
+
* refusal comes back with the policy's own sentence rather than a
|
|
192
|
+
* bare 403, because that sentence is the product.
|
|
193
|
+
*/
|
|
194
|
+
const result = body.undo
|
|
195
|
+
? store.undo(body.undo, { author: seat, ...(body.intent ? { intent: body.intent } : {}) })
|
|
196
|
+
: store.applyAll(calls, {
|
|
197
|
+
author: seat,
|
|
198
|
+
...(body.intent ? { intent: body.intent } : {}),
|
|
199
|
+
...(body.batch ? { batch: body.batch } : {}),
|
|
200
|
+
});
|
|
201
|
+
await opened.flush();
|
|
202
|
+
send(response, 200, { ops: result.ops, batch: result.batch });
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
send(response, 409, {
|
|
206
|
+
error: error instanceof Error ? error.message : String(error),
|
|
207
|
+
refused: true,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
send(response, 404, { error: `Nothing at ${url.pathname}` });
|
|
213
|
+
}
|
|
214
|
+
const port = await listen(server, options.port ?? 0);
|
|
215
|
+
return {
|
|
216
|
+
store,
|
|
217
|
+
opened,
|
|
218
|
+
server,
|
|
219
|
+
port,
|
|
220
|
+
url: `http://localhost:${port}`,
|
|
221
|
+
async close() {
|
|
222
|
+
await opened.flush();
|
|
223
|
+
opened.close();
|
|
224
|
+
await new Promise((done) => server.close(() => done()));
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function send(response, status, body) {
|
|
229
|
+
const text = JSON.stringify(body);
|
|
230
|
+
response.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) });
|
|
231
|
+
response.end(text);
|
|
232
|
+
}
|
|
233
|
+
async function read(request) {
|
|
234
|
+
const chunks = [];
|
|
235
|
+
for await (const chunk of request)
|
|
236
|
+
chunks.push(chunk);
|
|
237
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
238
|
+
return text.length === 0 ? {} : JSON.parse(text);
|
|
239
|
+
}
|
|
240
|
+
function listen(server, port) {
|
|
241
|
+
return new Promise((ready, fail) => {
|
|
242
|
+
server.once("error", fail);
|
|
243
|
+
server.listen(port, () => {
|
|
244
|
+
const address = server.address();
|
|
245
|
+
ready(typeof address === "object" && address !== null ? address.port : port);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=serve.js.map
|