lakebed 0.0.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/README.md +94 -0
- package/package.json +62 -0
- package/src/anonymous-server.js +1078 -0
- package/src/anonymous.js +996 -0
- package/src/cli.js +1066 -0
- package/src/client.d.ts +8 -0
- package/src/client.js +154 -0
- package/src/runtime.js +252 -0
- package/src/server.d.ts +53 -0
- package/src/server.js +39 -0
- package/src/source-store.js +110 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
createClaimToken,
|
|
4
|
+
createDeployId,
|
|
5
|
+
createSlug,
|
|
6
|
+
DEFAULT_ANONYMOUS_LIMITS,
|
|
7
|
+
executeAnonymousMutation,
|
|
8
|
+
executeAnonymousQuery,
|
|
9
|
+
hashClaimToken,
|
|
10
|
+
parseTtlSeconds,
|
|
11
|
+
validateAnonymousDeployPayload
|
|
12
|
+
} from "./anonymous.js";
|
|
13
|
+
import { WebSocketServer } from "ws";
|
|
14
|
+
|
|
15
|
+
function now() {
|
|
16
|
+
return new Date().toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dayWindowStart() {
|
|
20
|
+
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toGuestName(name) {
|
|
24
|
+
return (
|
|
25
|
+
String(name ?? "local")
|
|
26
|
+
.replace(/^guest:/, "")
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "")
|
|
30
|
+
.toLowerCase() || "local"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toDisplayName(name) {
|
|
35
|
+
return toGuestName(name)
|
|
36
|
+
.split(/[-_\s.]+/)
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
|
|
39
|
+
.join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createGuestAuth(name) {
|
|
43
|
+
const guestName = toGuestName(name);
|
|
44
|
+
return {
|
|
45
|
+
displayName: toDisplayName(guestName),
|
|
46
|
+
userId: `guest:${guestName}`
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function authFromUrl(url) {
|
|
51
|
+
return createGuestAuth(url.searchParams.get("lakebed_guest") ?? url.searchParams.get("guest") ?? "local");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function html(title, basePath) {
|
|
55
|
+
return `<!doctype html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="utf-8" />
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
60
|
+
<title>${title}</title>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div id="app"></div>
|
|
64
|
+
<script>window.__LAKEBED_BASE_PATH__ = ${JSON.stringify(basePath)};</script>
|
|
65
|
+
<script type="module" src="${basePath}/client.js"></script>
|
|
66
|
+
<script>
|
|
67
|
+
const tailwind = document.createElement("script");
|
|
68
|
+
tailwind.src = "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4";
|
|
69
|
+
tailwind.async = true;
|
|
70
|
+
document.head.appendChild(tailwind);
|
|
71
|
+
</script>
|
|
72
|
+
</body>
|
|
73
|
+
</html>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sendJson(res, status, value, headers = {}) {
|
|
77
|
+
res.writeHead(status, {
|
|
78
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
79
|
+
...headers
|
|
80
|
+
});
|
|
81
|
+
res.end(JSON.stringify(value, null, 2));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sendText(res, status, value, headers = {}) {
|
|
85
|
+
res.writeHead(status, headers);
|
|
86
|
+
res.end(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function websocketSend(ws, message) {
|
|
90
|
+
ws.send(JSON.stringify(message));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
let total = 0;
|
|
96
|
+
for await (const chunk of req) {
|
|
97
|
+
total += chunk.byteLength;
|
|
98
|
+
if (total > maxBytes) {
|
|
99
|
+
throw new Error(`Request body exceeds ${maxBytes} bytes.`);
|
|
100
|
+
}
|
|
101
|
+
chunks.push(chunk);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (chunks.length === 0) {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizePublicRootUrl(value, port) {
|
|
112
|
+
const fallback = `http://localhost:${port}`;
|
|
113
|
+
return String(value || fallback).replace(/\/+$/g, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
|
|
117
|
+
if (appBaseDomain) {
|
|
118
|
+
return `https://${slug}.${appBaseDomain}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return `${publicRootUrl}/d/${slug}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function claimUrlForDeploy({ deployId, publicRootUrl, token }) {
|
|
125
|
+
return `${publicRootUrl}/claim/${deployId}/${token}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function inspectUrls(url) {
|
|
129
|
+
return {
|
|
130
|
+
db: `${url}/__lakebed/db`,
|
|
131
|
+
logs: `${url}/__lakebed/logs`,
|
|
132
|
+
manifest: `${url}/__lakebed/manifest`,
|
|
133
|
+
usage: `${url}/__lakebed/usage`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function responseForDeploy({ deploy, token }) {
|
|
138
|
+
return {
|
|
139
|
+
claimUrl: token ? claimUrlForDeploy({ deployId: deploy.id, publicRootUrl: deploy.publicRootUrl, token }) : undefined,
|
|
140
|
+
deployId: deploy.id,
|
|
141
|
+
expiresAt: deploy.expiresAt,
|
|
142
|
+
inspect: inspectUrls(deploy.url),
|
|
143
|
+
limits: deploy.limits,
|
|
144
|
+
url: deploy.url
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isExpired(deploy) {
|
|
149
|
+
return Boolean(deploy.expiresAt) && Date.parse(deploy.expiresAt) <= Date.now();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function routeSystemPath(pathname) {
|
|
153
|
+
if (pathname.startsWith("/__span/")) {
|
|
154
|
+
return `/__lakebed/${pathname.slice("/__span/".length)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pathname === "/__span") {
|
|
158
|
+
return "/__lakebed";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return pathname;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parsePathDeploy(url) {
|
|
165
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
166
|
+
if (parts[0] !== "d" || !parts[1]) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const prefix = `/d/${parts[1]}`;
|
|
171
|
+
const appPath = url.pathname === prefix ? "/" : url.pathname.slice(prefix.length) || "/";
|
|
172
|
+
return {
|
|
173
|
+
appPath,
|
|
174
|
+
basePath: prefix,
|
|
175
|
+
slug: parts[1]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseHostDeploy({ appBaseDomain, host, url }) {
|
|
180
|
+
if (!appBaseDomain) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hostname = host.split(":")[0].toLowerCase();
|
|
185
|
+
const suffix = `.${appBaseDomain.toLowerCase()}`;
|
|
186
|
+
if (!hostname.endsWith(suffix)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const slug = hostname.slice(0, -suffix.length);
|
|
191
|
+
if (!slug || slug === "www") {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
appPath: url.pathname || "/",
|
|
197
|
+
basePath: "",
|
|
198
|
+
slug
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function quotaLimitForBucket(bucket, deploy) {
|
|
203
|
+
if (bucket === "mutations") {
|
|
204
|
+
return deploy.limits.mutationsPerDay;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return deploy.limits.requestsPerDay;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class MemoryAnonymousStore {
|
|
211
|
+
constructor() {
|
|
212
|
+
this.artifacts = new Map();
|
|
213
|
+
this.deploys = new Map();
|
|
214
|
+
this.deploysBySlug = new Map();
|
|
215
|
+
this.logs = new Map();
|
|
216
|
+
this.quotaEvents = new Map();
|
|
217
|
+
this.queues = new Map();
|
|
218
|
+
this.rows = new Map();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async initialize() {}
|
|
222
|
+
|
|
223
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
224
|
+
const deployId = createDeployId();
|
|
225
|
+
let slug = createSlug();
|
|
226
|
+
while (this.deploysBySlug.has(slug)) {
|
|
227
|
+
slug = createSlug();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const token = createClaimToken();
|
|
231
|
+
const createdAt = now();
|
|
232
|
+
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
233
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
234
|
+
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
235
|
+
|
|
236
|
+
const currentArtifact = this.artifacts.get(artifactHash);
|
|
237
|
+
this.artifacts.set(artifactHash, {
|
|
238
|
+
artifact,
|
|
239
|
+
bytes: Buffer.byteLength(clientBundleBase64, "base64"),
|
|
240
|
+
clientBundleBase64,
|
|
241
|
+
clientBundleHash,
|
|
242
|
+
createdAt,
|
|
243
|
+
hash: artifactHash,
|
|
244
|
+
refCount: (currentArtifact?.refCount ?? 0) + 1
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const deploy = {
|
|
248
|
+
appBaseDomain,
|
|
249
|
+
artifactHash,
|
|
250
|
+
claimTokenHash: hashClaimToken(token),
|
|
251
|
+
clientBundleHash,
|
|
252
|
+
createdAt,
|
|
253
|
+
expiresAt,
|
|
254
|
+
id: deployId,
|
|
255
|
+
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
256
|
+
publicRootUrl,
|
|
257
|
+
slug,
|
|
258
|
+
status: "active",
|
|
259
|
+
url
|
|
260
|
+
};
|
|
261
|
+
this.deploys.set(deployId, deploy);
|
|
262
|
+
this.deploysBySlug.set(slug, deployId);
|
|
263
|
+
return { deploy, token };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async getDeployById(id) {
|
|
267
|
+
return this.deploys.get(id) ?? null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async getDeployBySlug(slug) {
|
|
271
|
+
const id = this.deploysBySlug.get(slug);
|
|
272
|
+
return id ? this.getDeployById(id) : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async getArtifact(hash) {
|
|
276
|
+
return this.artifacts.get(hash) ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
rowKey(deployId, tableName) {
|
|
280
|
+
return `${deployId}:${tableName}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
tableRows(deployId, tableName) {
|
|
284
|
+
const key = this.rowKey(deployId, tableName);
|
|
285
|
+
if (!this.rows.has(key)) {
|
|
286
|
+
this.rows.set(key, new Map());
|
|
287
|
+
}
|
|
288
|
+
return this.rows.get(key);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async listRows(deployId, tableName) {
|
|
292
|
+
return Array.from(this.tableRows(deployId, tableName).values()).map((row) => ({ ...row }));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async getRow(deployId, tableName, rowId) {
|
|
296
|
+
const row = this.tableRows(deployId, tableName).get(rowId);
|
|
297
|
+
return row ? { ...row } : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async insertRow(deployId, tableName, row) {
|
|
301
|
+
this.tableRows(deployId, tableName).set(row.id, { ...row });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async updateRow(deployId, tableName, rowId, patch) {
|
|
305
|
+
const rows = this.tableRows(deployId, tableName);
|
|
306
|
+
const row = rows.get(rowId);
|
|
307
|
+
if (row) {
|
|
308
|
+
rows.set(rowId, { ...row, ...patch });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async deleteRow(deployId, tableName, rowId) {
|
|
313
|
+
this.tableRows(deployId, tableName).delete(rowId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async transaction(deployId, handler) {
|
|
317
|
+
const run = () => handler(this);
|
|
318
|
+
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
319
|
+
this.queues.set(
|
|
320
|
+
deployId,
|
|
321
|
+
next.then(
|
|
322
|
+
() => undefined,
|
|
323
|
+
() => undefined
|
|
324
|
+
)
|
|
325
|
+
);
|
|
326
|
+
return next;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async appendLog(deployId, level, message, data) {
|
|
330
|
+
const entries = this.logs.get(deployId) ?? [];
|
|
331
|
+
entries.push({ at: now(), data, level, message });
|
|
332
|
+
this.logs.set(deployId, entries.slice(-DEFAULT_ANONYMOUS_LIMITS.logEntries));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async readLogs(deployId, limit = 100) {
|
|
336
|
+
return (this.logs.get(deployId) ?? []).slice(-limit);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async tableCounts(deployId, schema) {
|
|
340
|
+
const counts = {};
|
|
341
|
+
for (const tableName of Object.keys(schema ?? {})) {
|
|
342
|
+
counts[tableName] = this.tableRows(deployId, tableName).size;
|
|
343
|
+
}
|
|
344
|
+
return counts;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async dumpState(deployId, schema, limit = 1000) {
|
|
348
|
+
const tables = {};
|
|
349
|
+
let remaining = limit;
|
|
350
|
+
let truncated = false;
|
|
351
|
+
|
|
352
|
+
for (const tableName of Object.keys(schema ?? {})) {
|
|
353
|
+
const rows = await this.listRows(deployId, tableName);
|
|
354
|
+
tables[tableName] = rows.slice(0, remaining);
|
|
355
|
+
remaining -= tables[tableName].length;
|
|
356
|
+
if (rows.length > tables[tableName].length) {
|
|
357
|
+
truncated = true;
|
|
358
|
+
}
|
|
359
|
+
if (remaining <= 0) {
|
|
360
|
+
truncated = true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { tables, truncated };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async incrementQuota(deployId, bucket, limit) {
|
|
368
|
+
const windowStart = dayWindowStart();
|
|
369
|
+
const key = `${deployId}:${bucket}:${windowStart}`;
|
|
370
|
+
const count = (this.quotaEvents.get(key) ?? 0) + 1;
|
|
371
|
+
this.quotaEvents.set(key, count);
|
|
372
|
+
if (count > limit) {
|
|
373
|
+
throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
|
|
374
|
+
}
|
|
375
|
+
return { bucket, count, limit, windowStart };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async readUsage(deployId) {
|
|
379
|
+
const usage = [];
|
|
380
|
+
for (const [key, count] of this.quotaEvents) {
|
|
381
|
+
const [eventDeployId, bucket, windowStart] = key.split(":");
|
|
382
|
+
if (eventDeployId === deployId) {
|
|
383
|
+
usage.push({ bucket, count, windowStart });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return usage;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export class PostgresAnonymousStore {
|
|
391
|
+
constructor({ connectionString }) {
|
|
392
|
+
this.connectionString = connectionString;
|
|
393
|
+
this.pool = null;
|
|
394
|
+
this.queues = new Map();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async initialize() {
|
|
398
|
+
const { Pool } = await import("pg");
|
|
399
|
+
this.pool = new Pool({ connectionString: this.connectionString });
|
|
400
|
+
await this.query(`
|
|
401
|
+
create table if not exists deploys(
|
|
402
|
+
id text primary key,
|
|
403
|
+
slug text unique not null,
|
|
404
|
+
status text not null,
|
|
405
|
+
artifact_hash text not null,
|
|
406
|
+
client_bundle_hash text not null,
|
|
407
|
+
created_at timestamptz not null,
|
|
408
|
+
expires_at timestamptz not null,
|
|
409
|
+
claimed_at timestamptz,
|
|
410
|
+
owner_id text,
|
|
411
|
+
claim_token_hash text not null,
|
|
412
|
+
limits_json jsonb not null,
|
|
413
|
+
counters_json jsonb not null default '{}',
|
|
414
|
+
public_root_url text not null,
|
|
415
|
+
app_base_domain text,
|
|
416
|
+
url text not null
|
|
417
|
+
)
|
|
418
|
+
`);
|
|
419
|
+
await this.query(`
|
|
420
|
+
create table if not exists artifacts(
|
|
421
|
+
hash text primary key,
|
|
422
|
+
artifact_json jsonb not null,
|
|
423
|
+
client_bundle_base64 text not null,
|
|
424
|
+
client_bundle_hash text not null,
|
|
425
|
+
bytes integer not null,
|
|
426
|
+
created_at timestamptz not null,
|
|
427
|
+
ref_count integer not null default 1
|
|
428
|
+
)
|
|
429
|
+
`);
|
|
430
|
+
await this.query(`
|
|
431
|
+
create table if not exists state_rows(
|
|
432
|
+
deploy_id text not null,
|
|
433
|
+
table_name text not null,
|
|
434
|
+
row_id text not null,
|
|
435
|
+
created_at timestamptz not null,
|
|
436
|
+
updated_at timestamptz not null,
|
|
437
|
+
data_json jsonb not null,
|
|
438
|
+
primary key (deploy_id, table_name, row_id)
|
|
439
|
+
)
|
|
440
|
+
`);
|
|
441
|
+
await this.query(`
|
|
442
|
+
create table if not exists logs(
|
|
443
|
+
deploy_id text not null,
|
|
444
|
+
sequence bigserial primary key,
|
|
445
|
+
level text not null,
|
|
446
|
+
message text not null,
|
|
447
|
+
data_json jsonb,
|
|
448
|
+
created_at timestamptz not null
|
|
449
|
+
)
|
|
450
|
+
`);
|
|
451
|
+
await this.query(`
|
|
452
|
+
create table if not exists quota_events(
|
|
453
|
+
deploy_id text not null,
|
|
454
|
+
bucket text not null,
|
|
455
|
+
window_start timestamptz not null,
|
|
456
|
+
count integer not null,
|
|
457
|
+
primary key (deploy_id, bucket, window_start)
|
|
458
|
+
)
|
|
459
|
+
`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async query(sql, params = []) {
|
|
463
|
+
if (!this.pool) {
|
|
464
|
+
throw new Error("Postgres store is not initialized.");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return this.pool.query(sql, params);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
|
|
471
|
+
const createdAt = now();
|
|
472
|
+
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
473
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
474
|
+
const token = createClaimToken();
|
|
475
|
+
const deployId = createDeployId();
|
|
476
|
+
|
|
477
|
+
await this.query(
|
|
478
|
+
`
|
|
479
|
+
insert into artifacts(hash, artifact_json, client_bundle_base64, client_bundle_hash, bytes, created_at, ref_count)
|
|
480
|
+
values($1, $2::jsonb, $3, $4, $5, $6, 1)
|
|
481
|
+
on conflict(hash) do update set ref_count = artifacts.ref_count + 1
|
|
482
|
+
`,
|
|
483
|
+
[artifactHash, JSON.stringify(artifact), clientBundleBase64, clientBundleHash, Buffer.byteLength(clientBundleBase64, "base64"), createdAt]
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
487
|
+
const slug = createSlug();
|
|
488
|
+
const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
|
|
489
|
+
const deploy = {
|
|
490
|
+
appBaseDomain,
|
|
491
|
+
artifactHash,
|
|
492
|
+
claimTokenHash: hashClaimToken(token),
|
|
493
|
+
clientBundleHash,
|
|
494
|
+
createdAt,
|
|
495
|
+
expiresAt,
|
|
496
|
+
id: deployId,
|
|
497
|
+
limits: { ...DEFAULT_ANONYMOUS_LIMITS },
|
|
498
|
+
publicRootUrl,
|
|
499
|
+
slug,
|
|
500
|
+
status: "active",
|
|
501
|
+
url
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await this.query(
|
|
506
|
+
`
|
|
507
|
+
insert into deploys(
|
|
508
|
+
id, slug, status, artifact_hash, client_bundle_hash, created_at, expires_at,
|
|
509
|
+
claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
|
|
510
|
+
)
|
|
511
|
+
values($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, '{}'::jsonb, $10, $11, $12)
|
|
512
|
+
`,
|
|
513
|
+
[
|
|
514
|
+
deploy.id,
|
|
515
|
+
deploy.slug,
|
|
516
|
+
deploy.status,
|
|
517
|
+
deploy.artifactHash,
|
|
518
|
+
deploy.clientBundleHash,
|
|
519
|
+
deploy.createdAt,
|
|
520
|
+
deploy.expiresAt,
|
|
521
|
+
deploy.claimTokenHash,
|
|
522
|
+
JSON.stringify(deploy.limits),
|
|
523
|
+
deploy.publicRootUrl,
|
|
524
|
+
deploy.appBaseDomain,
|
|
525
|
+
deploy.url
|
|
526
|
+
]
|
|
527
|
+
);
|
|
528
|
+
return { deploy, token };
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (error?.code !== "23505" || attempt === 7) {
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
throw new Error("Unable to allocate anonymous deploy slug.");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
rowToDeploy(row) {
|
|
540
|
+
if (!row) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
appBaseDomain: row.app_base_domain,
|
|
546
|
+
artifactHash: row.artifact_hash,
|
|
547
|
+
claimTokenHash: row.claim_token_hash,
|
|
548
|
+
clientBundleHash: row.client_bundle_hash,
|
|
549
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
550
|
+
expiresAt: new Date(row.expires_at).toISOString(),
|
|
551
|
+
id: row.id,
|
|
552
|
+
limits: row.limits_json,
|
|
553
|
+
publicRootUrl: row.public_root_url,
|
|
554
|
+
slug: row.slug,
|
|
555
|
+
status: row.status,
|
|
556
|
+
url: row.url
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async getDeployById(id) {
|
|
561
|
+
const result = await this.query("select * from deploys where id = $1", [id]);
|
|
562
|
+
return this.rowToDeploy(result.rows[0]);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async getDeployBySlug(slug) {
|
|
566
|
+
const result = await this.query("select * from deploys where slug = $1", [slug]);
|
|
567
|
+
return this.rowToDeploy(result.rows[0]);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async getArtifact(hash) {
|
|
571
|
+
const result = await this.query("select * from artifacts where hash = $1", [hash]);
|
|
572
|
+
const row = result.rows[0];
|
|
573
|
+
if (!row) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
artifact: row.artifact_json,
|
|
579
|
+
bytes: row.bytes,
|
|
580
|
+
clientBundleBase64: row.client_bundle_base64,
|
|
581
|
+
clientBundleHash: row.client_bundle_hash,
|
|
582
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
583
|
+
hash: row.hash,
|
|
584
|
+
refCount: row.ref_count
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async listRows(deployId, tableName) {
|
|
589
|
+
const result = await this.query("select data_json from state_rows where deploy_id = $1 and table_name = $2", [deployId, tableName]);
|
|
590
|
+
return result.rows.map((row) => row.data_json);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async getRow(deployId, tableName, rowId) {
|
|
594
|
+
const result = await this.query("select data_json from state_rows where deploy_id = $1 and table_name = $2 and row_id = $3", [
|
|
595
|
+
deployId,
|
|
596
|
+
tableName,
|
|
597
|
+
rowId
|
|
598
|
+
]);
|
|
599
|
+
return result.rows[0]?.data_json ?? null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async insertRow(deployId, tableName, row) {
|
|
603
|
+
await this.query(
|
|
604
|
+
`
|
|
605
|
+
insert into state_rows(deploy_id, table_name, row_id, created_at, updated_at, data_json)
|
|
606
|
+
values($1, $2, $3, $4, $5, $6::jsonb)
|
|
607
|
+
`,
|
|
608
|
+
[deployId, tableName, row.id, row.createdAt, row.updatedAt, JSON.stringify(row)]
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async updateRow(deployId, tableName, rowId, patch) {
|
|
613
|
+
const row = await this.getRow(deployId, tableName, rowId);
|
|
614
|
+
if (!row) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const next = { ...row, ...patch };
|
|
618
|
+
await this.query(
|
|
619
|
+
`
|
|
620
|
+
update state_rows
|
|
621
|
+
set updated_at = $4, data_json = $5::jsonb
|
|
622
|
+
where deploy_id = $1 and table_name = $2 and row_id = $3
|
|
623
|
+
`,
|
|
624
|
+
[deployId, tableName, rowId, next.updatedAt, JSON.stringify(next)]
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async deleteRow(deployId, tableName, rowId) {
|
|
629
|
+
await this.query("delete from state_rows where deploy_id = $1 and table_name = $2 and row_id = $3", [deployId, tableName, rowId]);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async transaction(deployId, handler) {
|
|
633
|
+
const run = () => handler(this);
|
|
634
|
+
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
635
|
+
this.queues.set(
|
|
636
|
+
deployId,
|
|
637
|
+
next.then(
|
|
638
|
+
() => undefined,
|
|
639
|
+
() => undefined
|
|
640
|
+
)
|
|
641
|
+
);
|
|
642
|
+
return next;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async appendLog(deployId, level, message, data) {
|
|
646
|
+
await this.query(
|
|
647
|
+
"insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
|
|
648
|
+
[deployId, level, message, JSON.stringify(data ?? null), now()]
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async readLogs(deployId, limit = 100) {
|
|
653
|
+
const result = await this.query(
|
|
654
|
+
"select level, message, data_json, created_at from logs where deploy_id = $1 order by sequence desc limit $2",
|
|
655
|
+
[deployId, limit]
|
|
656
|
+
);
|
|
657
|
+
return result.rows.reverse().map((row) => ({
|
|
658
|
+
at: new Date(row.created_at).toISOString(),
|
|
659
|
+
data: row.data_json,
|
|
660
|
+
level: row.level,
|
|
661
|
+
message: row.message
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async tableCounts(deployId, schema) {
|
|
666
|
+
const counts = {};
|
|
667
|
+
for (const tableName of Object.keys(schema ?? {})) {
|
|
668
|
+
const result = await this.query("select count(*)::int as count from state_rows where deploy_id = $1 and table_name = $2", [
|
|
669
|
+
deployId,
|
|
670
|
+
tableName
|
|
671
|
+
]);
|
|
672
|
+
counts[tableName] = result.rows[0]?.count ?? 0;
|
|
673
|
+
}
|
|
674
|
+
return counts;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async dumpState(deployId, schema, limit = 1000) {
|
|
678
|
+
const tables = {};
|
|
679
|
+
let remaining = limit;
|
|
680
|
+
let truncated = false;
|
|
681
|
+
for (const tableName of Object.keys(schema ?? {})) {
|
|
682
|
+
const result = await this.query(
|
|
683
|
+
"select data_json from state_rows where deploy_id = $1 and table_name = $2 order by created_at asc limit $3",
|
|
684
|
+
[deployId, tableName, Math.max(0, remaining)]
|
|
685
|
+
);
|
|
686
|
+
tables[tableName] = result.rows.map((row) => row.data_json);
|
|
687
|
+
remaining -= tables[tableName].length;
|
|
688
|
+
const count = await this.tableCounts(deployId, { [tableName]: schema[tableName] });
|
|
689
|
+
if (count[tableName] > tables[tableName].length) {
|
|
690
|
+
truncated = true;
|
|
691
|
+
}
|
|
692
|
+
if (remaining <= 0) {
|
|
693
|
+
truncated = true;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return { tables, truncated };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async incrementQuota(deployId, bucket, limit) {
|
|
701
|
+
const windowStart = dayWindowStart();
|
|
702
|
+
const result = await this.query(
|
|
703
|
+
`
|
|
704
|
+
insert into quota_events(deploy_id, bucket, window_start, count)
|
|
705
|
+
values($1, $2, $3, 1)
|
|
706
|
+
on conflict(deploy_id, bucket, window_start)
|
|
707
|
+
do update set count = quota_events.count + 1
|
|
708
|
+
returning count
|
|
709
|
+
`,
|
|
710
|
+
[deployId, bucket, windowStart]
|
|
711
|
+
);
|
|
712
|
+
const count = result.rows[0].count;
|
|
713
|
+
if (count > limit) {
|
|
714
|
+
throw new Error(`Anonymous ${bucket} quota exceeded. Limit: ${limit} per day.`);
|
|
715
|
+
}
|
|
716
|
+
return { bucket, count, limit, windowStart };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async readUsage(deployId) {
|
|
720
|
+
const result = await this.query("select bucket, window_start, count from quota_events where deploy_id = $1 order by window_start desc", [
|
|
721
|
+
deployId
|
|
722
|
+
]);
|
|
723
|
+
return result.rows.map((row) => ({
|
|
724
|
+
bucket: row.bucket,
|
|
725
|
+
count: row.count,
|
|
726
|
+
windowStart: new Date(row.window_start).toISOString()
|
|
727
|
+
}));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
732
|
+
if (env.DATABASE_URL) {
|
|
733
|
+
const store = new PostgresAnonymousStore({ connectionString: env.DATABASE_URL });
|
|
734
|
+
await store.initialize();
|
|
735
|
+
return store;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const store = new MemoryAnonymousStore();
|
|
739
|
+
await store.initialize();
|
|
740
|
+
return store;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
|
|
744
|
+
const route = parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
|
|
745
|
+
if (!route) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const deploy = await store.getDeployBySlug(route.slug);
|
|
750
|
+
if (!deploy) {
|
|
751
|
+
return { error: "Unknown anonymous deploy.", route, status: 404 };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (deploy.status !== "active") {
|
|
755
|
+
return { error: "Anonymous deploy is not active.", route, status: 410 };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (isExpired(deploy)) {
|
|
759
|
+
return { error: "Anonymous deploy has expired.", route, status: 410 };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const storedArtifact = await store.getArtifact(deploy.artifactHash);
|
|
763
|
+
if (!storedArtifact) {
|
|
764
|
+
return { error: "Anonymous deploy artifact is missing.", route, status: 500 };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return { artifact: storedArtifact.artifact, basePath: route.basePath, deploy, route, storedArtifact };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function serveInspect({ artifact, deploy, route, store, systemPath }, res) {
|
|
771
|
+
if (systemPath === "/__lakebed/manifest") {
|
|
772
|
+
sendJson(res, 200, {
|
|
773
|
+
artifactHash: deploy.artifactHash,
|
|
774
|
+
clientBundleHash: deploy.clientBundleHash,
|
|
775
|
+
deployId: deploy.id,
|
|
776
|
+
expiresAt: deploy.expiresAt,
|
|
777
|
+
limits: deploy.limits,
|
|
778
|
+
mutations: Object.keys(artifact.server.mutations ?? {}),
|
|
779
|
+
queries: Object.keys(artifact.server.queries ?? {}),
|
|
780
|
+
schema: artifact.server.schema,
|
|
781
|
+
slug: deploy.slug,
|
|
782
|
+
url: deploy.url
|
|
783
|
+
});
|
|
784
|
+
return true;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (systemPath === "/__lakebed/db/tables") {
|
|
788
|
+
const counts = await store.tableCounts(deploy.id, artifact.server.schema);
|
|
789
|
+
sendJson(res, 200, {
|
|
790
|
+
tables: Object.keys(artifact.server.schema ?? {}),
|
|
791
|
+
counts
|
|
792
|
+
});
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (systemPath === "/__lakebed/db") {
|
|
797
|
+
sendJson(res, 200, await store.dumpState(deploy.id, artifact.server.schema, deploy.limits.rowsReturned));
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (systemPath === "/__lakebed/logs") {
|
|
802
|
+
sendJson(res, 200, await store.readLogs(deploy.id, 100));
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (systemPath === "/__lakebed/usage") {
|
|
807
|
+
sendJson(res, 200, {
|
|
808
|
+
limits: deploy.limits,
|
|
809
|
+
usage: await store.readUsage(deploy.id)
|
|
810
|
+
});
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (systemPath === "/__lakebed/url") {
|
|
815
|
+
sendJson(res, 200, {
|
|
816
|
+
basePath: route.basePath,
|
|
817
|
+
url: deploy.url
|
|
818
|
+
});
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function startAnonymousServer({
|
|
826
|
+
appBaseDomain = process.env.LAKEBED_APP_BASE_DOMAIN ?? "",
|
|
827
|
+
port = Number(process.env.PORT ?? 8787),
|
|
828
|
+
publicRootUrl,
|
|
829
|
+
quiet = false,
|
|
830
|
+
store
|
|
831
|
+
} = {}) {
|
|
832
|
+
const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
|
|
833
|
+
const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
|
|
834
|
+
await resolvedStore.initialize();
|
|
835
|
+
const subscriptions = new Map();
|
|
836
|
+
|
|
837
|
+
async function publishDeploy(deployId) {
|
|
838
|
+
for (const [ws, subscription] of subscriptions) {
|
|
839
|
+
if (subscription.deploy.id !== deployId) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
for (const name of subscription.queries) {
|
|
844
|
+
try {
|
|
845
|
+
const data = await executeAnonymousQuery({
|
|
846
|
+
artifact: subscription.artifact,
|
|
847
|
+
auth: subscription.auth,
|
|
848
|
+
deployId,
|
|
849
|
+
name,
|
|
850
|
+
state: resolvedStore
|
|
851
|
+
});
|
|
852
|
+
websocketSend(ws, { data, name, op: "query.result" });
|
|
853
|
+
} catch (error) {
|
|
854
|
+
websocketSend(ws, { error: error instanceof Error ? error.message : String(error), name, op: "query.error" });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const server = createServer(async (req, res) => {
|
|
861
|
+
const host = req.headers.host ?? "localhost";
|
|
862
|
+
const requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
if (req.method === "GET" && requestUrl.pathname === "/healthz") {
|
|
866
|
+
sendJson(res, 200, { ok: true });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
|
|
871
|
+
const body = await readJsonBody(req);
|
|
872
|
+
const payload = validateAnonymousDeployPayload(body);
|
|
873
|
+
const { deploy, token } = await resolvedStore.createDeploy({
|
|
874
|
+
appBaseDomain,
|
|
875
|
+
artifact: payload.artifact,
|
|
876
|
+
artifactHash: payload.artifactHash,
|
|
877
|
+
clientBundleBase64: payload.clientBundleBase64,
|
|
878
|
+
clientBundleHash: payload.clientBundleHash,
|
|
879
|
+
publicRootUrl: resolvedPublicRootUrl,
|
|
880
|
+
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
|
|
881
|
+
});
|
|
882
|
+
await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
|
|
883
|
+
sendJson(res, 201, responseForDeploy({ deploy, token }));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
|
|
888
|
+
const id = requestUrl.pathname.slice("/v1/deploys/".length);
|
|
889
|
+
const deploy = (await resolvedStore.getDeployById(id)) ?? (await resolvedStore.getDeployBySlug(id));
|
|
890
|
+
if (!deploy) {
|
|
891
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
sendJson(res, 200, responseForDeploy({ deploy }));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
899
|
+
if (!loaded) {
|
|
900
|
+
sendText(res, 200, "Lakebed anonymous deploy runner\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (loaded.error) {
|
|
905
|
+
sendJson(res, loaded.status, { error: loaded.error });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
await resolvedStore.incrementQuota(
|
|
910
|
+
loaded.deploy.id,
|
|
911
|
+
"requests",
|
|
912
|
+
quotaLimitForBucket("requests", loaded.deploy)
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const appPath = routeSystemPath(loaded.route.appPath);
|
|
916
|
+
if (req.method === "GET" && (appPath === "/" || appPath === "/index.html")) {
|
|
917
|
+
sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath), {
|
|
918
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (req.method === "GET" && appPath === "/client.js") {
|
|
924
|
+
sendText(res, 200, Buffer.from(loaded.storedArtifact.clientBundleBase64, "base64"), {
|
|
925
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
926
|
+
"Content-Type": "application/javascript; charset=utf-8"
|
|
927
|
+
});
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (req.method === "GET" && (await serveInspect({ ...loaded, store: resolvedStore, systemPath: appPath }, res))) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
942
|
+
|
|
943
|
+
wss.on("connection", (ws, _req, loaded, auth) => {
|
|
944
|
+
subscriptions.set(ws, {
|
|
945
|
+
artifact: loaded.artifact,
|
|
946
|
+
auth,
|
|
947
|
+
deploy: loaded.deploy,
|
|
948
|
+
queries: new Set()
|
|
949
|
+
});
|
|
950
|
+
websocketSend(ws, { auth, op: "auth.result" });
|
|
951
|
+
|
|
952
|
+
ws.on("message", async (raw) => {
|
|
953
|
+
const subscription = subscriptions.get(ws);
|
|
954
|
+
if (!subscription) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let message;
|
|
959
|
+
try {
|
|
960
|
+
message = JSON.parse(String(raw));
|
|
961
|
+
} catch {
|
|
962
|
+
websocketSend(ws, { error: "Invalid JSON message.", op: "error", ok: false });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
await resolvedStore.incrementQuota(
|
|
968
|
+
subscription.deploy.id,
|
|
969
|
+
"requests",
|
|
970
|
+
quotaLimitForBucket("requests", subscription.deploy)
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
if (message.op === "auth.get") {
|
|
974
|
+
websocketSend(ws, { auth: subscription.auth, id: message.id, ok: true, op: "auth.result" });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (message.op === "query.subscribe") {
|
|
979
|
+
subscription.queries.add(message.name);
|
|
980
|
+
const data = await executeAnonymousQuery({
|
|
981
|
+
artifact: subscription.artifact,
|
|
982
|
+
auth: subscription.auth,
|
|
983
|
+
deployId: subscription.deploy.id,
|
|
984
|
+
name: message.name,
|
|
985
|
+
state: resolvedStore
|
|
986
|
+
});
|
|
987
|
+
websocketSend(ws, { data, id: message.id, name: message.name, ok: true, op: "query.result" });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (message.op === "mutation.run") {
|
|
992
|
+
await resolvedStore.incrementQuota(
|
|
993
|
+
subscription.deploy.id,
|
|
994
|
+
"mutations",
|
|
995
|
+
quotaLimitForBucket("mutations", subscription.deploy)
|
|
996
|
+
);
|
|
997
|
+
const result = await executeAnonymousMutation({
|
|
998
|
+
args: message.args ?? [],
|
|
999
|
+
artifact: subscription.artifact,
|
|
1000
|
+
auth: subscription.auth,
|
|
1001
|
+
deployId: subscription.deploy.id,
|
|
1002
|
+
name: message.name,
|
|
1003
|
+
state: resolvedStore
|
|
1004
|
+
});
|
|
1005
|
+
websocketSend(ws, { id: message.id, ok: true, op: "mutation.result", result });
|
|
1006
|
+
await publishDeploy(subscription.deploy.id);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
throw new Error(`Unknown operation: ${message.op}`);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
await resolvedStore.appendLog(subscription.deploy.id, "error", "websocket operation failed", {
|
|
1013
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1014
|
+
op: message?.op
|
|
1015
|
+
});
|
|
1016
|
+
websocketSend(ws, {
|
|
1017
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1018
|
+
id: message?.id,
|
|
1019
|
+
ok: false,
|
|
1020
|
+
op: "error"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
ws.on("close", () => {
|
|
1026
|
+
subscriptions.delete(ws);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
server.on("upgrade", async (req, socket, head) => {
|
|
1031
|
+
const host = req.headers.host ?? "localhost";
|
|
1032
|
+
const requestUrl = new URL(req.url ?? "/", `http://${host}`);
|
|
1033
|
+
const loaded = await loadDeployByRoute({ appBaseDomain, host, store: resolvedStore, url: requestUrl });
|
|
1034
|
+
|
|
1035
|
+
if (!loaded || loaded.error || routeSystemPath(loaded.route.appPath) !== "/__lakebed/ws") {
|
|
1036
|
+
socket.destroy();
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const auth = authFromUrl(requestUrl);
|
|
1041
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1042
|
+
wss.emit("connection", ws, req, loaded, auth);
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
1047
|
+
server.once("error", rejectListen);
|
|
1048
|
+
server.listen(port, () => {
|
|
1049
|
+
server.off("error", rejectListen);
|
|
1050
|
+
resolveListen();
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
if (!quiet) {
|
|
1055
|
+
console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
port,
|
|
1060
|
+
publicRootUrl: resolvedPublicRootUrl,
|
|
1061
|
+
store: resolvedStore,
|
|
1062
|
+
url: resolvedPublicRootUrl,
|
|
1063
|
+
async close() {
|
|
1064
|
+
for (const client of wss.clients) {
|
|
1065
|
+
client.close();
|
|
1066
|
+
}
|
|
1067
|
+
await new Promise((resolveClose) => {
|
|
1068
|
+
wss.close(() => {
|
|
1069
|
+
server.close(() => resolveClose());
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
export async function main() {
|
|
1077
|
+
await startAnonymousServer();
|
|
1078
|
+
}
|