lakebed 0.0.14 → 0.0.16
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 +32 -3
- package/package.json +1 -1
- package/src/anonymous-server.js +1868 -139
- package/src/anonymous.js +25 -5
- package/src/cli.js +132 -18
- package/src/source-runtime.js +2 -1
- package/src/version.js +1 -1
package/src/anonymous-server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { domainToASCII } from "node:url";
|
|
3
5
|
import {
|
|
4
6
|
createClaimToken,
|
|
5
7
|
createDeployId,
|
|
@@ -19,6 +21,11 @@ function now() {
|
|
|
19
21
|
return new Date().toISOString();
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
function anonymousDeployExpiresAt() {
|
|
25
|
+
const ttlSeconds = parseTtlSeconds();
|
|
26
|
+
return new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
function dayWindowStart() {
|
|
23
30
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
24
31
|
}
|
|
@@ -160,6 +167,106 @@ function normalizeAppBaseDomain(value) {
|
|
|
160
167
|
.toLowerCase();
|
|
161
168
|
}
|
|
162
169
|
|
|
170
|
+
const reservedLakebedSubdomainLabels = new Set([
|
|
171
|
+
"admin",
|
|
172
|
+
"api",
|
|
173
|
+
"app",
|
|
174
|
+
"assets",
|
|
175
|
+
"auth",
|
|
176
|
+
"billing",
|
|
177
|
+
"cdn",
|
|
178
|
+
"cname",
|
|
179
|
+
"console",
|
|
180
|
+
"dashboard",
|
|
181
|
+
"docs",
|
|
182
|
+
"ftp",
|
|
183
|
+
"imap",
|
|
184
|
+
"login",
|
|
185
|
+
"mail",
|
|
186
|
+
"ns1",
|
|
187
|
+
"ns2",
|
|
188
|
+
"origin",
|
|
189
|
+
"pop",
|
|
190
|
+
"proxy-fallback",
|
|
191
|
+
"smtp",
|
|
192
|
+
"static",
|
|
193
|
+
"status",
|
|
194
|
+
"support",
|
|
195
|
+
"www"
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
function normalizeHostname(value) {
|
|
199
|
+
const trimmed = String(value ?? "")
|
|
200
|
+
.trim()
|
|
201
|
+
.replace(/\.$/, "")
|
|
202
|
+
.toLowerCase();
|
|
203
|
+
if (!trimmed) {
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return domainToASCII(trimmed).toLowerCase();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hostnameFromHost(host) {
|
|
211
|
+
const value = String(host ?? "").trim();
|
|
212
|
+
if (value.startsWith("[")) {
|
|
213
|
+
const end = value.indexOf("]");
|
|
214
|
+
return normalizeHostname(end === -1 ? value : value.slice(1, end));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return normalizeHostname(value.split(":")[0]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateLakebedSubdomainLabel(label) {
|
|
221
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
|
|
222
|
+
throw new Error("Lakebed subdomains must use one DNS label with letters, numbers, or hyphens.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (reservedLakebedSubdomainLabels.has(label)) {
|
|
226
|
+
throw new Error(`The subdomain ${label} is reserved for Lakebed.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeLakebedSubdomainInput(value, appBaseDomain) {
|
|
231
|
+
const baseDomain = normalizeHostname(appBaseDomain);
|
|
232
|
+
if (!baseDomain) {
|
|
233
|
+
throw new Error("Lakebed app subdomains are not configured on this runner.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const raw = String(value ?? "").trim();
|
|
237
|
+
if (!raw) {
|
|
238
|
+
throw new Error("Subdomain is required.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes("/") || raw.includes("\\") || raw.includes(":")) {
|
|
242
|
+
throw new Error("Enter a subdomain like my-app.lakebed.app, without a scheme, port, or path.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const hostname = normalizeHostname(raw);
|
|
246
|
+
if (!hostname) {
|
|
247
|
+
throw new Error("Subdomain is not a valid hostname.");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const suffix = `.${baseDomain}`;
|
|
251
|
+
let label;
|
|
252
|
+
if (hostname.endsWith(suffix)) {
|
|
253
|
+
label = hostname.slice(0, -suffix.length);
|
|
254
|
+
if (!label || label.includes(".")) {
|
|
255
|
+
throw new Error(`Lakebed subdomains must be exactly one label under ${baseDomain}.`);
|
|
256
|
+
}
|
|
257
|
+
} else if (!hostname.includes(".")) {
|
|
258
|
+
label = hostname;
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error(`Lakebed subdomains must end with ${baseDomain}.`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
validateLakebedSubdomainLabel(label);
|
|
264
|
+
return {
|
|
265
|
+
hostname: `${label}.${baseDomain}`,
|
|
266
|
+
label
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
163
270
|
function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
|
|
164
271
|
if (appBaseDomain) {
|
|
165
272
|
return `https://${slug}.${appBaseDomain}`;
|
|
@@ -195,6 +302,19 @@ function responseForDeploy({ deploy, token }) {
|
|
|
195
302
|
};
|
|
196
303
|
}
|
|
197
304
|
|
|
305
|
+
function responseForDeployDomain(domain) {
|
|
306
|
+
return {
|
|
307
|
+
createdAt: domain.createdAt,
|
|
308
|
+
deployId: domain.deployId,
|
|
309
|
+
hostname: domain.hostname,
|
|
310
|
+
kind: domain.kind,
|
|
311
|
+
primary: Boolean(domain.isPrimary),
|
|
312
|
+
status: domain.status,
|
|
313
|
+
updatedAt: domain.updatedAt,
|
|
314
|
+
url: domain.url
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
198
318
|
function isExpired(deploy) {
|
|
199
319
|
if (deploy?.ownerId) {
|
|
200
320
|
return false;
|
|
@@ -235,14 +355,14 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
|
|
|
235
355
|
return null;
|
|
236
356
|
}
|
|
237
357
|
|
|
238
|
-
const hostname = host
|
|
358
|
+
const hostname = hostnameFromHost(host);
|
|
239
359
|
const suffix = `.${appBaseDomain.toLowerCase()}`;
|
|
240
360
|
if (!hostname.endsWith(suffix)) {
|
|
241
361
|
return null;
|
|
242
362
|
}
|
|
243
363
|
|
|
244
364
|
const slug = hostname.slice(0, -suffix.length);
|
|
245
|
-
if (!slug || slug
|
|
365
|
+
if (!slug || slug.includes(".") || reservedLakebedSubdomainLabels.has(slug)) {
|
|
246
366
|
return null;
|
|
247
367
|
}
|
|
248
368
|
|
|
@@ -261,6 +381,138 @@ function quotaLimitForBucket(bucket, deploy) {
|
|
|
261
381
|
return deploy.limits.requestsPerDay;
|
|
262
382
|
}
|
|
263
383
|
|
|
384
|
+
function clientTrafficQuotaBucket(bucket) {
|
|
385
|
+
return bucket === "mutations" ? "anonymous_client_mutations" : "anonymous_client_requests";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function clientTrafficQuotaSuggestion(bucket) {
|
|
389
|
+
return bucket === "mutations"
|
|
390
|
+
? "Retry after reset or reduce mutation frequency from this client."
|
|
391
|
+
: "Retry after reset or reduce request frequency from this client.";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function anonymousDeployCreationPolicy({ env = process.env, publicRootUrl }) {
|
|
395
|
+
const production = !isLocalPublicRootUrl(publicRootUrl);
|
|
396
|
+
const disabled =
|
|
397
|
+
booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_DISABLED) ||
|
|
398
|
+
booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOYS_DISABLED);
|
|
399
|
+
const defaultPerClient = production ? 50 : Number.POSITIVE_INFINITY;
|
|
400
|
+
const defaultGlobal = production ? 5000 : Number.POSITIVE_INFINITY;
|
|
401
|
+
return {
|
|
402
|
+
disabled,
|
|
403
|
+
globalLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_GLOBAL_PER_DAY, defaultGlobal),
|
|
404
|
+
perClientLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_PER_CLIENT_PER_DAY, defaultPerClient),
|
|
405
|
+
production
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function anonymousClientTrafficPolicy({ env = process.env, publicRootUrl }) {
|
|
410
|
+
const production = !isLocalPublicRootUrl(publicRootUrl);
|
|
411
|
+
return {
|
|
412
|
+
mutationsPerClientLimit: positiveIntegerLimitFromEnv(
|
|
413
|
+
env.LAKEBED_ANONYMOUS_MUTATIONS_PER_CLIENT_PER_DAY,
|
|
414
|
+
production ? 10000 : Number.POSITIVE_INFINITY
|
|
415
|
+
),
|
|
416
|
+
requestsPerClientLimit: positiveIntegerLimitFromEnv(
|
|
417
|
+
env.LAKEBED_ANONYMOUS_REQUESTS_PER_CLIENT_PER_DAY,
|
|
418
|
+
production ? 100000 : Number.POSITIVE_INFINITY
|
|
419
|
+
)
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function cleanupPolicyFromEnv(env = process.env) {
|
|
424
|
+
return {
|
|
425
|
+
graceSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_GRACE, 60 * 60, { min: 0 }),
|
|
426
|
+
intervalSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_INTERVAL, 60 * 60, { min: 60 }),
|
|
427
|
+
retentionSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_RETENTION, 7 * 24 * 60 * 60, { min: 0 })
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizePeerAddress(value) {
|
|
432
|
+
let address = String(value ?? "").trim();
|
|
433
|
+
if (!address) {
|
|
434
|
+
return "unknown";
|
|
435
|
+
}
|
|
436
|
+
if (address.startsWith("::ffff:")) {
|
|
437
|
+
address = address.slice("::ffff:".length);
|
|
438
|
+
}
|
|
439
|
+
if (address.startsWith("[") && address.includes("]")) {
|
|
440
|
+
address = address.slice(1, address.indexOf("]"));
|
|
441
|
+
}
|
|
442
|
+
if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(address)) {
|
|
443
|
+
address = address.slice(0, address.lastIndexOf(":"));
|
|
444
|
+
}
|
|
445
|
+
return address;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function ipv4ToNumber(address) {
|
|
449
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
450
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
return parts.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ipv4InCidr(address, cidr) {
|
|
457
|
+
const [base, rawBits] = cidr.split("/");
|
|
458
|
+
const bits = Number(rawBits);
|
|
459
|
+
const value = ipv4ToNumber(address);
|
|
460
|
+
const baseValue = ipv4ToNumber(base);
|
|
461
|
+
if (value === null || baseValue === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
466
|
+
return (value & mask) === (baseValue & mask);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function hasRailwayRuntimeEnv(env = process.env) {
|
|
470
|
+
return Boolean(env.RAILWAY_SERVICE_ID || env.RAILWAY_PROJECT_ID || env.RAILWAY_DEPLOYMENT_ID || env.RAILWAY_REPLICA_ID);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isRailwayProxyPeer(req, env = process.env) {
|
|
474
|
+
const edge = String(req.headers["x-railway-edge"] ?? "");
|
|
475
|
+
if (!hasRailwayRuntimeEnv(env) || !/^railway\/[a-z0-9][a-z0-9-]*$/i.test(edge)) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Railway's proxy guidance treats 100.0.0.0/8 as the trusted internal proxy range.
|
|
480
|
+
const address = normalizePeerAddress(req.socket.remoteAddress);
|
|
481
|
+
return isIP(address) === 4 && ipv4InCidr(address, "100.0.0.0/8");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function shouldTrustProxyHeaders(req, env = process.env) {
|
|
485
|
+
if (booleanFromEnv(env.LAKEBED_TRUST_PROXY_HEADERS)) {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return isRailwayProxyPeer(req, env);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function firstHeaderValue(req, names) {
|
|
493
|
+
for (const name of names) {
|
|
494
|
+
const raw = req.headers[name];
|
|
495
|
+
const candidate = String(Array.isArray(raw) ? raw[0] : (raw ?? ""))
|
|
496
|
+
.split(",")[0]
|
|
497
|
+
.trim();
|
|
498
|
+
if (candidate) {
|
|
499
|
+
return normalizePeerAddress(candidate).slice(0, 256);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return "";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function forwardedClientKey(req, env = process.env) {
|
|
506
|
+
if (shouldTrustProxyHeaders(req, env)) {
|
|
507
|
+
const forwarded = firstHeaderValue(req, ["x-real-ip", "cf-connecting-ip", "fly-client-ip", "x-forwarded-for"]);
|
|
508
|
+
if (forwarded) {
|
|
509
|
+
return forwarded;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return normalizePeerAddress(req.socket.remoteAddress).slice(0, 256);
|
|
514
|
+
}
|
|
515
|
+
|
|
264
516
|
const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
|
|
265
517
|
const USER_BOOST_MULTIPLIER = 20;
|
|
266
518
|
|
|
@@ -660,6 +912,193 @@ function bytesOfJson(value) {
|
|
|
660
912
|
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
|
|
661
913
|
}
|
|
662
914
|
|
|
915
|
+
function durationSecondsFromEnv(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
916
|
+
if (value === undefined || value === null || value === "") {
|
|
917
|
+
return fallback;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const match = String(value).trim().match(/^(\d+)([smhd])?$/);
|
|
921
|
+
if (!match) {
|
|
922
|
+
throw new Error(`Invalid duration: ${value}. Use a value like 15m, 1h, 7d, or 604800.`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const multipliers = { d: 86400, h: 3600, m: 60, s: 1 };
|
|
926
|
+
const seconds = Number(match[1]) * multipliers[match[2] ?? "s"];
|
|
927
|
+
return Math.max(min, Math.min(max, seconds));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function positiveIntegerLimitFromEnv(value, fallback) {
|
|
931
|
+
if (value === undefined || value === null || value === "") {
|
|
932
|
+
return fallback;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const parsed = Number(value);
|
|
936
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
937
|
+
throw new Error(`Invalid limit: ${value}. Use a non-negative integer.`);
|
|
938
|
+
}
|
|
939
|
+
return parsed;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function isLocalPublicRootUrl(publicRootUrl) {
|
|
943
|
+
try {
|
|
944
|
+
const hostname = new URL(publicRootUrl).hostname.toLowerCase();
|
|
945
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
|
|
946
|
+
} catch {
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function booleanFromEnv(value) {
|
|
952
|
+
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function quotaResetAt(windowStart) {
|
|
956
|
+
return new Date(Date.parse(windowStart) + 24 * 60 * 60 * 1000).toISOString();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function quotaRetryAfterSeconds(windowStart) {
|
|
960
|
+
return Math.max(1, Math.ceil((Date.parse(quotaResetAt(windowStart)) - Date.now()) / 1000));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export class LakebedQuotaError extends Error {
|
|
964
|
+
constructor({ bucket, count, limit, resetAt, retryAfterSeconds, status = 429, suggestion }) {
|
|
965
|
+
super(`${bucket} quota exceeded. Limit: ${limit}.`);
|
|
966
|
+
this.name = "LakebedQuotaError";
|
|
967
|
+
this.bucket = bucket;
|
|
968
|
+
this.code = "lakebed_quota_exceeded";
|
|
969
|
+
this.count = count;
|
|
970
|
+
this.limit = limit;
|
|
971
|
+
this.resetAt = resetAt ?? null;
|
|
972
|
+
this.retryAfterSeconds = retryAfterSeconds ?? null;
|
|
973
|
+
this.status = status;
|
|
974
|
+
this.suggestion = suggestion;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function isQuotaError(error) {
|
|
979
|
+
return error instanceof LakebedQuotaError || error?.code === "lakebed_quota_exceeded";
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function quotaErrorBody(error, extra = {}) {
|
|
983
|
+
return {
|
|
984
|
+
bucket: error.bucket,
|
|
985
|
+
code: error.code ?? "lakebed_quota_exceeded",
|
|
986
|
+
current: error.count,
|
|
987
|
+
error: error.message,
|
|
988
|
+
limit: error.limit,
|
|
989
|
+
resetAt: error.resetAt ?? null,
|
|
990
|
+
retryAfterSeconds: error.retryAfterSeconds ?? null,
|
|
991
|
+
suggestion: error.suggestion,
|
|
992
|
+
...extra
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function quotaErrorHeaders(error) {
|
|
997
|
+
const headers = {};
|
|
998
|
+
if (Number.isFinite(error.retryAfterSeconds)) {
|
|
999
|
+
headers["Retry-After"] = String(error.retryAfterSeconds);
|
|
1000
|
+
}
|
|
1001
|
+
if (Number.isFinite(error.limit)) {
|
|
1002
|
+
headers["X-RateLimit-Limit"] = String(error.limit);
|
|
1003
|
+
}
|
|
1004
|
+
if (Number.isFinite(error.count) && Number.isFinite(error.limit)) {
|
|
1005
|
+
headers["X-RateLimit-Remaining"] = String(Math.max(0, error.limit - error.count));
|
|
1006
|
+
}
|
|
1007
|
+
if (error.resetAt) {
|
|
1008
|
+
headers["X-RateLimit-Reset"] = error.resetAt;
|
|
1009
|
+
}
|
|
1010
|
+
return headers;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function stateRowsLimitFromTransactionOptions(options = {}) {
|
|
1014
|
+
const explicit = options.stateRowsLimit;
|
|
1015
|
+
if (Number.isSafeInteger(explicit) && explicit > 0) {
|
|
1016
|
+
return explicit;
|
|
1017
|
+
}
|
|
1018
|
+
const stateBytes = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
|
|
1019
|
+
return Math.max(1, Math.floor(stateBytes / 64));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function assertStateResourceLimits({ stateBytes, stateRows }, options = {}) {
|
|
1023
|
+
const stateBytesLimit = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
|
|
1024
|
+
const stateRowsLimit = stateRowsLimitFromTransactionOptions(options);
|
|
1025
|
+
if (Number.isFinite(stateRowsLimit) && stateRows > stateRowsLimit) {
|
|
1026
|
+
throw new LakebedQuotaError({
|
|
1027
|
+
bucket: "state_rows",
|
|
1028
|
+
count: stateRows,
|
|
1029
|
+
limit: stateRowsLimit,
|
|
1030
|
+
suggestion: "Delete rows or claim the deploy before retrying this mutation."
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
if (Number.isFinite(stateBytesLimit) && stateBytes > stateBytesLimit) {
|
|
1034
|
+
throw new LakebedQuotaError({
|
|
1035
|
+
bucket: "state_bytes",
|
|
1036
|
+
count: stateBytes,
|
|
1037
|
+
limit: stateBytesLimit,
|
|
1038
|
+
suggestion: "Reduce stored values, delete rows, or claim the deploy before retrying this mutation."
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function safeJsonValue(value) {
|
|
1044
|
+
if (value === undefined) {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
JSON.stringify(value);
|
|
1050
|
+
return value;
|
|
1051
|
+
} catch {
|
|
1052
|
+
return String(value);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function truncateUtf8(value, maxBytes) {
|
|
1057
|
+
const text = String(value ?? "");
|
|
1058
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
|
|
1059
|
+
return text;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
let end = text.length;
|
|
1063
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
|
|
1064
|
+
end -= 1;
|
|
1065
|
+
}
|
|
1066
|
+
return text.slice(0, end);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function normalizeLogEntry(level, message, data, limits = DEFAULT_ANONYMOUS_LIMITS) {
|
|
1070
|
+
const maxEntryBytes = limits.logEntryBytes ?? DEFAULT_ANONYMOUS_LIMITS.logEntryBytes;
|
|
1071
|
+
const maxMessageBytes = Math.max(128, Math.min(maxEntryBytes, Math.floor(maxEntryBytes / 2)));
|
|
1072
|
+
const originalMessageBytes = Buffer.byteLength(String(message ?? ""), "utf8");
|
|
1073
|
+
const cleanMessage =
|
|
1074
|
+
originalMessageBytes > maxMessageBytes
|
|
1075
|
+
? `${truncateUtf8(message, Math.max(0, maxMessageBytes - 18))}...[truncated]`
|
|
1076
|
+
: String(message ?? "");
|
|
1077
|
+
let cleanData = safeJsonValue(data);
|
|
1078
|
+
let entry = { at: now(), data: cleanData, level, message: cleanMessage };
|
|
1079
|
+
|
|
1080
|
+
if (bytesOfJson(entry) > maxEntryBytes) {
|
|
1081
|
+
const dataText = JSON.stringify(cleanData ?? null);
|
|
1082
|
+
cleanData = {
|
|
1083
|
+
preview: truncateUtf8(dataText, Math.max(0, maxEntryBytes - bytesOfJson({ at: entry.at, data: null, level, message: cleanMessage }) - 128)),
|
|
1084
|
+
truncated: true,
|
|
1085
|
+
originalBytes: Buffer.byteLength(dataText, "utf8")
|
|
1086
|
+
};
|
|
1087
|
+
entry = { ...entry, data: cleanData };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (bytesOfJson(entry) > maxEntryBytes) {
|
|
1091
|
+
entry = {
|
|
1092
|
+
at: entry.at,
|
|
1093
|
+
data: { truncated: true },
|
|
1094
|
+
level,
|
|
1095
|
+
message: truncateUtf8(cleanMessage, Math.max(64, maxEntryBytes - 96))
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return entry;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
663
1102
|
function usageCounts(usage) {
|
|
664
1103
|
const windowStart = dayWindowStart();
|
|
665
1104
|
const counts = {
|
|
@@ -891,7 +1330,7 @@ function adminHtml() {
|
|
|
891
1330
|
.metrics {
|
|
892
1331
|
display: grid;
|
|
893
1332
|
gap: 8px;
|
|
894
|
-
grid-template-columns: repeat(
|
|
1333
|
+
grid-template-columns: repeat(8, minmax(120px, 1fr));
|
|
895
1334
|
margin-bottom: 14px;
|
|
896
1335
|
}
|
|
897
1336
|
|
|
@@ -1273,8 +1712,10 @@ function adminHtml() {
|
|
|
1273
1712
|
<div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
|
|
1274
1713
|
<div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
|
|
1275
1714
|
<div class="metric"><span>state rows</span><strong id="metric-rows">0</strong></div>
|
|
1715
|
+
<div class="metric"><span>log bytes</span><strong id="metric-logs">0 B</strong></div>
|
|
1276
1716
|
<div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
|
|
1277
1717
|
<div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
|
|
1718
|
+
<div class="metric"><span>cleaned deploys</span><strong id="metric-cleanup">0</strong></div>
|
|
1278
1719
|
</section>
|
|
1279
1720
|
|
|
1280
1721
|
<section class="panel" id="deployments-view">
|
|
@@ -1875,8 +2316,10 @@ function adminHtml() {
|
|
|
1875
2316
|
setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
|
|
1876
2317
|
setMetric("metric-state", formatBytes(summary.totals.stateBytes));
|
|
1877
2318
|
setMetric("metric-rows", formatNumber(summary.totals.stateRows));
|
|
2319
|
+
setMetric("metric-logs", formatBytes(summary.totals.logBytes));
|
|
1878
2320
|
setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
|
|
1879
2321
|
setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
|
|
2322
|
+
setMetric("metric-cleanup", formatNumber(summary.cleanup?.totals?.deletedDeploys || 0));
|
|
1880
2323
|
statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
|
|
1881
2324
|
}
|
|
1882
2325
|
|
|
@@ -2220,12 +2663,109 @@ function escapeHtml(value) {
|
|
|
2220
2663
|
.replace(/"/g, """);
|
|
2221
2664
|
}
|
|
2222
2665
|
|
|
2223
|
-
function
|
|
2224
|
-
return
|
|
2666
|
+
function formatHtmlNumber(value) {
|
|
2667
|
+
return new Intl.NumberFormat().format(Number(value || 0));
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function parseHtmlDate(value) {
|
|
2671
|
+
if (!value) {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2675
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function formatHtmlAbsoluteTime(value) {
|
|
2679
|
+
const date = parseHtmlDate(value);
|
|
2680
|
+
if (!date) {
|
|
2681
|
+
return "unknown";
|
|
2682
|
+
}
|
|
2683
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
2684
|
+
day: "numeric",
|
|
2685
|
+
hour: "numeric",
|
|
2686
|
+
minute: "2-digit",
|
|
2687
|
+
month: "short",
|
|
2688
|
+
timeZoneName: "short",
|
|
2689
|
+
year: "numeric"
|
|
2690
|
+
}).format(date);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function relativeHtmlUnit(diffMs, unitMs) {
|
|
2694
|
+
const value = Math.round(diffMs / unitMs);
|
|
2695
|
+
if (value === 0) {
|
|
2696
|
+
return diffMs < 0 ? -1 : 1;
|
|
2697
|
+
}
|
|
2698
|
+
return value;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function formatHtmlRelativeTime(value, baseDate = new Date()) {
|
|
2702
|
+
const date = parseHtmlDate(value);
|
|
2703
|
+
if (!date) {
|
|
2704
|
+
return "unknown";
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
const diffMs = date.getTime() - baseDate.getTime();
|
|
2708
|
+
const absMs = Math.abs(diffMs);
|
|
2709
|
+
const second = 1000;
|
|
2710
|
+
const minute = 60 * second;
|
|
2711
|
+
const hour = 60 * minute;
|
|
2712
|
+
const day = 24 * hour;
|
|
2713
|
+
const week = 7 * day;
|
|
2714
|
+
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
2715
|
+
|
|
2716
|
+
if (absMs < 45 * second) {
|
|
2717
|
+
return diffMs < 0 ? "just now" : "soon";
|
|
2718
|
+
}
|
|
2719
|
+
if (absMs < 45 * minute) {
|
|
2720
|
+
return formatter.format(relativeHtmlUnit(diffMs, minute), "minute");
|
|
2721
|
+
}
|
|
2722
|
+
if (absMs < 22 * hour) {
|
|
2723
|
+
return formatter.format(relativeHtmlUnit(diffMs, hour), "hour");
|
|
2724
|
+
}
|
|
2725
|
+
if (absMs < 26 * day) {
|
|
2726
|
+
return formatter.format(relativeHtmlUnit(diffMs, day), "day");
|
|
2727
|
+
}
|
|
2728
|
+
if (absMs < 8 * week) {
|
|
2729
|
+
return formatter.format(relativeHtmlUnit(diffMs, week), "week");
|
|
2730
|
+
}
|
|
2731
|
+
return formatHtmlAbsoluteTime(date);
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
function htmlTime(value) {
|
|
2735
|
+
const date = parseHtmlDate(value);
|
|
2736
|
+
if (!date) {
|
|
2737
|
+
return `<span>unknown</span>`;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
return `<time datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(formatHtmlAbsoluteTime(date))}">${escapeHtml(formatHtmlRelativeTime(date))}</time>`;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function cssClassToken(value) {
|
|
2744
|
+
return (
|
|
2745
|
+
String(value ?? "")
|
|
2746
|
+
.toLowerCase()
|
|
2747
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
2748
|
+
.replace(/^-+|-+$/g, "") || "unknown"
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function quotaPercent(used, limit) {
|
|
2753
|
+
const numericLimit = Number(limit || 0);
|
|
2754
|
+
if (numericLimit <= 0) {
|
|
2755
|
+
return 0;
|
|
2756
|
+
}
|
|
2757
|
+
return Math.max(0, Math.min(100, (Number(used || 0) / numericLimit) * 100));
|
|
2225
2758
|
}
|
|
2226
2759
|
|
|
2227
|
-
function
|
|
2228
|
-
|
|
2760
|
+
function quotaHtml(label, used, limit) {
|
|
2761
|
+
const percent = quotaPercent(used, limit).toFixed(2);
|
|
2762
|
+
return `<div class="quota">
|
|
2763
|
+
<div class="quota-row">
|
|
2764
|
+
<span class="quota-label">${escapeHtml(label)}</span>
|
|
2765
|
+
<span class="quota-count">${escapeHtml(formatHtmlNumber(used))} / ${escapeHtml(formatHtmlNumber(limit))}</span>
|
|
2766
|
+
</div>
|
|
2767
|
+
<div class="meter" aria-hidden="true"><span style="--usage: ${percent}%"></span></div>
|
|
2768
|
+
</div>`;
|
|
2229
2769
|
}
|
|
2230
2770
|
|
|
2231
2771
|
function developerDeploySummary({ artifact, deploy, usage }) {
|
|
@@ -2250,18 +2790,34 @@ function developerDeploySummary({ artifact, deploy, usage }) {
|
|
|
2250
2790
|
|
|
2251
2791
|
function developerHtml({ authConfigured, deploys = [], user }) {
|
|
2252
2792
|
const signedIn = Boolean(user);
|
|
2793
|
+
const activeDeploys = deploys.filter((deploy) => deploy.status === "active").length;
|
|
2794
|
+
const requestsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.requestsToday || 0), 0);
|
|
2795
|
+
const mutationsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.mutationsToday || 0), 0);
|
|
2253
2796
|
const rows = deploys
|
|
2254
|
-
.map(
|
|
2255
|
-
(deploy)
|
|
2256
|
-
|
|
2257
|
-
<
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2797
|
+
.map((deploy) => {
|
|
2798
|
+
const statusClass = cssClassToken(deploy.status);
|
|
2799
|
+
return `<article class="deploy-row">
|
|
2800
|
+
<div class="deploy-main">
|
|
2801
|
+
<div class="deploy-title-line">
|
|
2802
|
+
<a class="deploy-name" href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a>
|
|
2803
|
+
<span class="status status-${escapeHtml(statusClass)}">${escapeHtml(deploy.status)}</span>
|
|
2804
|
+
${deploy.expiresAt ? `<span class="expiry">expires ${htmlTime(deploy.expiresAt)}</span>` : ""}
|
|
2805
|
+
</div>
|
|
2806
|
+
<div class="deploy-ids">
|
|
2807
|
+
<span>${escapeHtml(deploy.deployId)}</span>
|
|
2808
|
+
<span>${escapeHtml(deploy.slug)}</span>
|
|
2809
|
+
</div>
|
|
2810
|
+
</div>
|
|
2811
|
+
<div class="activity">
|
|
2812
|
+
<div class="activity-item"><span>Updated</span>${htmlTime(deploy.updatedAt)}</div>
|
|
2813
|
+
<div class="activity-item"><span>Created</span>${htmlTime(deploy.createdAt)}</div>
|
|
2814
|
+
</div>
|
|
2815
|
+
<div class="usage-grid">
|
|
2816
|
+
${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
|
|
2817
|
+
${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
|
|
2818
|
+
</div>
|
|
2819
|
+
</article>`;
|
|
2820
|
+
})
|
|
2265
2821
|
.join("");
|
|
2266
2822
|
|
|
2267
2823
|
return `<!doctype html>
|
|
@@ -2273,14 +2829,18 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2273
2829
|
<style>
|
|
2274
2830
|
:root {
|
|
2275
2831
|
color-scheme: dark;
|
|
2276
|
-
--
|
|
2277
|
-
--
|
|
2278
|
-
--
|
|
2279
|
-
--
|
|
2280
|
-
--
|
|
2281
|
-
--
|
|
2282
|
-
--
|
|
2283
|
-
--
|
|
2832
|
+
--accent: #b7f26d;
|
|
2833
|
+
--bg: #070a09;
|
|
2834
|
+
--cyan: #71d6ff;
|
|
2835
|
+
--danger: #ff7b72;
|
|
2836
|
+
--line: #26302b;
|
|
2837
|
+
--line-strong: #3b4740;
|
|
2838
|
+
--muted: #8f9c94;
|
|
2839
|
+
--panel: #0f1513;
|
|
2840
|
+
--panel-raised: #141b18;
|
|
2841
|
+
--soft: #bfcbc3;
|
|
2842
|
+
--text: #eef6f0;
|
|
2843
|
+
--warning: #ffd166;
|
|
2284
2844
|
}
|
|
2285
2845
|
|
|
2286
2846
|
* {
|
|
@@ -2290,13 +2850,12 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2290
2850
|
body {
|
|
2291
2851
|
background: var(--bg);
|
|
2292
2852
|
color: var(--text);
|
|
2293
|
-
font-family:
|
|
2294
|
-
letter-spacing: 0;
|
|
2853
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2295
2854
|
margin: 0;
|
|
2296
2855
|
}
|
|
2297
2856
|
|
|
2298
2857
|
a {
|
|
2299
|
-
color:
|
|
2858
|
+
color: inherit;
|
|
2300
2859
|
text-decoration: none;
|
|
2301
2860
|
}
|
|
2302
2861
|
|
|
@@ -2306,50 +2865,104 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2306
2865
|
|
|
2307
2866
|
.shell {
|
|
2308
2867
|
margin: 0 auto;
|
|
2309
|
-
max-width:
|
|
2868
|
+
max-width: 1560px;
|
|
2310
2869
|
min-height: 100vh;
|
|
2311
|
-
padding:
|
|
2870
|
+
padding: 34px 32px 56px;
|
|
2871
|
+
width: 100%;
|
|
2312
2872
|
}
|
|
2313
2873
|
|
|
2314
2874
|
header {
|
|
2315
2875
|
align-items: center;
|
|
2876
|
+
border-bottom: 1px solid var(--line);
|
|
2316
2877
|
display: flex;
|
|
2317
2878
|
gap: 16px;
|
|
2318
2879
|
justify-content: space-between;
|
|
2319
|
-
margin-bottom:
|
|
2880
|
+
margin-bottom: 22px;
|
|
2881
|
+
padding-bottom: 22px;
|
|
2320
2882
|
}
|
|
2321
2883
|
|
|
2322
2884
|
h1 {
|
|
2323
|
-
font-size:
|
|
2324
|
-
line-height: 1;
|
|
2885
|
+
font-size: 40px;
|
|
2886
|
+
line-height: 1.05;
|
|
2325
2887
|
margin: 0;
|
|
2326
2888
|
}
|
|
2327
2889
|
|
|
2328
2890
|
.eyebrow,
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
.
|
|
2891
|
+
.meta,
|
|
2892
|
+
.deploy-ids,
|
|
2893
|
+
.activity-item span,
|
|
2894
|
+
.expiry,
|
|
2895
|
+
.list-head,
|
|
2896
|
+
.quota-label,
|
|
2897
|
+
.quota-count,
|
|
2898
|
+
.status {
|
|
2332
2899
|
color: var(--muted);
|
|
2333
2900
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
2334
2901
|
font-size: 12px;
|
|
2335
2902
|
}
|
|
2336
2903
|
|
|
2904
|
+
.eyebrow {
|
|
2905
|
+
color: var(--accent);
|
|
2906
|
+
margin-bottom: 6px;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
.meta {
|
|
2910
|
+
margin-top: 4px;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2337
2913
|
.button {
|
|
2338
|
-
|
|
2339
|
-
|
|
2914
|
+
align-items: center;
|
|
2915
|
+
background: transparent;
|
|
2916
|
+
border: 1px solid var(--line-strong);
|
|
2340
2917
|
border-radius: 6px;
|
|
2341
|
-
color: var(--
|
|
2918
|
+
color: var(--text);
|
|
2342
2919
|
display: inline-flex;
|
|
2343
2920
|
font-weight: 700;
|
|
2344
2921
|
min-height: 40px;
|
|
2345
2922
|
padding: 10px 14px;
|
|
2346
2923
|
}
|
|
2347
2924
|
|
|
2925
|
+
.button:hover {
|
|
2926
|
+
border-color: var(--accent);
|
|
2927
|
+
text-decoration: none;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2348
2930
|
.button.secondary {
|
|
2349
2931
|
background: transparent;
|
|
2350
2932
|
color: var(--text);
|
|
2351
2933
|
}
|
|
2352
2934
|
|
|
2935
|
+
.summary {
|
|
2936
|
+
background: var(--line);
|
|
2937
|
+
border: 1px solid var(--line);
|
|
2938
|
+
border-radius: 8px;
|
|
2939
|
+
display: grid;
|
|
2940
|
+
gap: 1px;
|
|
2941
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2942
|
+
margin-bottom: 18px;
|
|
2943
|
+
overflow: hidden;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
.metric {
|
|
2947
|
+
background: var(--panel);
|
|
2948
|
+
padding: 15px 16px;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
.metric span {
|
|
2952
|
+
color: var(--muted);
|
|
2953
|
+
display: block;
|
|
2954
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
2955
|
+
font-size: 12px;
|
|
2956
|
+
margin-bottom: 6px;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
.metric strong {
|
|
2960
|
+
color: var(--text);
|
|
2961
|
+
display: block;
|
|
2962
|
+
font-size: 22px;
|
|
2963
|
+
line-height: 1;
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2353
2966
|
.panel {
|
|
2354
2967
|
background: var(--panel);
|
|
2355
2968
|
border: 1px solid var(--line);
|
|
@@ -2362,51 +2975,160 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2362
2975
|
padding: 22px;
|
|
2363
2976
|
}
|
|
2364
2977
|
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
width: 100%;
|
|
2978
|
+
.deploy-list {
|
|
2979
|
+
display: grid;
|
|
2368
2980
|
}
|
|
2369
2981
|
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2982
|
+
.list-head,
|
|
2983
|
+
.deploy-row {
|
|
2984
|
+
align-items: center;
|
|
2985
|
+
display: grid;
|
|
2986
|
+
gap: 24px;
|
|
2987
|
+
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
|
|
2376
2988
|
}
|
|
2377
2989
|
|
|
2378
|
-
|
|
2990
|
+
.list-head {
|
|
2991
|
+
background: #0b100e;
|
|
2992
|
+
border-bottom: 1px solid var(--line);
|
|
2993
|
+
padding: 12px 18px;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
.deploy-row {
|
|
2997
|
+
background: var(--panel);
|
|
2998
|
+
border-bottom: 1px solid var(--line);
|
|
2999
|
+
min-width: 0;
|
|
3000
|
+
padding: 18px;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
.deploy-row:last-child {
|
|
2379
3004
|
border-bottom: 0;
|
|
2380
3005
|
}
|
|
2381
3006
|
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
gap: 4px;
|
|
3007
|
+
.deploy-row:hover {
|
|
3008
|
+
background: var(--panel-raised);
|
|
2385
3009
|
}
|
|
2386
3010
|
|
|
2387
|
-
.
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
font-size: 12px;
|
|
2393
|
-
padding: 3px 8px;
|
|
3011
|
+
.deploy-main,
|
|
3012
|
+
.activity,
|
|
3013
|
+
.usage-grid,
|
|
3014
|
+
.quota {
|
|
3015
|
+
min-width: 0;
|
|
2394
3016
|
}
|
|
2395
3017
|
|
|
2396
|
-
.
|
|
2397
|
-
|
|
3018
|
+
.deploy-title-line {
|
|
3019
|
+
align-items: center;
|
|
3020
|
+
display: flex;
|
|
3021
|
+
flex-wrap: wrap;
|
|
3022
|
+
gap: 8px 10px;
|
|
3023
|
+
margin-bottom: 8px;
|
|
3024
|
+
min-width: 0;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
.deploy-name {
|
|
3028
|
+
color: var(--text);
|
|
3029
|
+
font-size: 16px;
|
|
3030
|
+
font-weight: 700;
|
|
3031
|
+
overflow-wrap: anywhere;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
.deploy-ids {
|
|
3035
|
+
align-items: center;
|
|
3036
|
+
display: flex;
|
|
3037
|
+
flex-wrap: wrap;
|
|
3038
|
+
gap: 6px 12px;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
.deploy-ids span {
|
|
3042
|
+
overflow-wrap: anywhere;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
.status {
|
|
3046
|
+
align-items: center;
|
|
2398
3047
|
color: var(--accent);
|
|
3048
|
+
display: inline-flex;
|
|
3049
|
+
gap: 6px;
|
|
3050
|
+
white-space: nowrap;
|
|
2399
3051
|
}
|
|
2400
3052
|
|
|
2401
|
-
.
|
|
2402
|
-
|
|
2403
|
-
border-
|
|
2404
|
-
|
|
3053
|
+
.status::before {
|
|
3054
|
+
background: var(--accent);
|
|
3055
|
+
border-radius: 999px;
|
|
3056
|
+
box-shadow: 0 0 0 3px rgba(183, 242, 109, 0.14);
|
|
3057
|
+
content: "";
|
|
3058
|
+
height: 7px;
|
|
3059
|
+
width: 7px;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
.status-expired,
|
|
3063
|
+
.status-terminated {
|
|
3064
|
+
color: var(--danger);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
.status-expired::before,
|
|
3068
|
+
.status-terminated::before {
|
|
3069
|
+
background: var(--danger);
|
|
3070
|
+
box-shadow: 0 0 0 3px rgba(255, 123, 114, 0.15);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
.expiry {
|
|
3074
|
+
color: var(--warning);
|
|
3075
|
+
white-space: nowrap;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
.activity {
|
|
3079
|
+
display: grid;
|
|
3080
|
+
gap: 6px;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
.activity-item {
|
|
3084
|
+
align-items: baseline;
|
|
3085
|
+
color: var(--soft);
|
|
3086
|
+
display: flex;
|
|
3087
|
+
gap: 12px;
|
|
3088
|
+
justify-content: space-between;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
time {
|
|
3092
|
+
color: var(--text);
|
|
3093
|
+
white-space: nowrap;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
.usage-grid {
|
|
3097
|
+
display: grid;
|
|
3098
|
+
gap: 12px;
|
|
3099
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
.quota-row {
|
|
3103
|
+
align-items: baseline;
|
|
3104
|
+
display: flex;
|
|
3105
|
+
gap: 12px;
|
|
3106
|
+
justify-content: space-between;
|
|
3107
|
+
margin-bottom: 8px;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
.quota-count {
|
|
3111
|
+
color: var(--text);
|
|
3112
|
+
white-space: nowrap;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
.meter {
|
|
3116
|
+
background: #222b26;
|
|
3117
|
+
border-radius: 999px;
|
|
3118
|
+
height: 6px;
|
|
3119
|
+
overflow: hidden;
|
|
2405
3120
|
}
|
|
2406
3121
|
|
|
2407
|
-
|
|
3122
|
+
.meter span {
|
|
3123
|
+
background: linear-gradient(90deg, var(--accent), var(--cyan));
|
|
3124
|
+
display: block;
|
|
3125
|
+
height: 100%;
|
|
3126
|
+
width: var(--usage);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
@media (max-width: 980px) {
|
|
2408
3130
|
.shell {
|
|
2409
|
-
padding: 18px;
|
|
3131
|
+
padding: 24px 18px 40px;
|
|
2410
3132
|
}
|
|
2411
3133
|
|
|
2412
3134
|
header {
|
|
@@ -2414,12 +3136,46 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2414
3136
|
flex-direction: column;
|
|
2415
3137
|
}
|
|
2416
3138
|
|
|
2417
|
-
|
|
2418
|
-
|
|
3139
|
+
h1 {
|
|
3140
|
+
font-size: 32px;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
.summary {
|
|
3144
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
.list-head {
|
|
3148
|
+
display: none;
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
.deploy-row {
|
|
3152
|
+
align-items: stretch;
|
|
3153
|
+
gap: 16px;
|
|
3154
|
+
grid-template-columns: 1fr;
|
|
2419
3155
|
}
|
|
2420
3156
|
|
|
2421
|
-
.
|
|
2422
|
-
|
|
3157
|
+
.activity-item {
|
|
3158
|
+
justify-content: flex-start;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
@media (max-width: 640px) {
|
|
3163
|
+
.shell {
|
|
3164
|
+
padding: 20px 12px 36px;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
.summary,
|
|
3168
|
+
.usage-grid {
|
|
3169
|
+
grid-template-columns: 1fr;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
.deploy-ids {
|
|
3173
|
+
display: grid;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
.activity-item {
|
|
3177
|
+
display: grid;
|
|
3178
|
+
gap: 2px;
|
|
2423
3179
|
}
|
|
2424
3180
|
}
|
|
2425
3181
|
</style>
|
|
@@ -2442,6 +3198,15 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2442
3198
|
}
|
|
2443
3199
|
</div>
|
|
2444
3200
|
</header>
|
|
3201
|
+
${
|
|
3202
|
+
signedIn && deploys.length
|
|
3203
|
+
? `<section class="summary" aria-label="Deployment summary">
|
|
3204
|
+
<div class="metric"><span>active deploys</span><strong>${escapeHtml(formatHtmlNumber(activeDeploys))}</strong></div>
|
|
3205
|
+
<div class="metric"><span>requests today</span><strong>${escapeHtml(formatHtmlNumber(requestsToday))}</strong></div>
|
|
3206
|
+
<div class="metric"><span>mutations today</span><strong>${escapeHtml(formatHtmlNumber(mutationsToday))}</strong></div>
|
|
3207
|
+
</section>`
|
|
3208
|
+
: ""
|
|
3209
|
+
}
|
|
2445
3210
|
|
|
2446
3211
|
${
|
|
2447
3212
|
!authConfigured
|
|
@@ -2451,20 +3216,14 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2451
3216
|
: deploys.length === 0
|
|
2452
3217
|
? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
|
|
2453
3218
|
: `<section class="panel">
|
|
2454
|
-
<
|
|
2455
|
-
<
|
|
2456
|
-
<
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
<th>Requests</th>
|
|
2463
|
-
<th>Mutations</th>
|
|
2464
|
-
</tr>
|
|
2465
|
-
</thead>
|
|
2466
|
-
<tbody>${rows}</tbody>
|
|
2467
|
-
</table>
|
|
3219
|
+
<div class="deploy-list">
|
|
3220
|
+
<div class="list-head" aria-hidden="true">
|
|
3221
|
+
<span>Deploy</span>
|
|
3222
|
+
<span>Activity</span>
|
|
3223
|
+
<span>Usage today</span>
|
|
3224
|
+
</div>
|
|
3225
|
+
${rows}
|
|
3226
|
+
</div>
|
|
2468
3227
|
</section>`
|
|
2469
3228
|
}
|
|
2470
3229
|
</main>
|
|
@@ -2475,14 +3234,19 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2475
3234
|
export class MemoryAnonymousStore {
|
|
2476
3235
|
constructor() {
|
|
2477
3236
|
this.artifacts = new Map();
|
|
3237
|
+
this.deployDomainsByHostname = new Map();
|
|
2478
3238
|
this.deploys = new Map();
|
|
2479
3239
|
this.deploysBySlug = new Map();
|
|
3240
|
+
this.deployCreateQuotaEvents = new Map();
|
|
3241
|
+
this.clientQuotaEvents = new Map();
|
|
2480
3242
|
this.logs = new Map();
|
|
2481
3243
|
this.quotaEvents = new Map();
|
|
2482
3244
|
this.queues = new Map();
|
|
2483
3245
|
this.rows = new Map();
|
|
2484
3246
|
this.serverEnv = new Map();
|
|
2485
3247
|
this.users = new Map();
|
|
3248
|
+
this.cleanupRuns = [];
|
|
3249
|
+
this.cleanupTotals = {};
|
|
2486
3250
|
}
|
|
2487
3251
|
|
|
2488
3252
|
async initialize() {}
|
|
@@ -2609,18 +3373,36 @@ export class MemoryAnonymousStore {
|
|
|
2609
3373
|
});
|
|
2610
3374
|
}
|
|
2611
3375
|
|
|
2612
|
-
|
|
3376
|
+
decrementArtifactRef(artifactHash) {
|
|
3377
|
+
const artifact = this.artifacts.get(artifactHash);
|
|
3378
|
+
if (!artifact) {
|
|
3379
|
+
return false;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
const refCount = Math.max(0, Number(artifact.refCount ?? 0) - 1);
|
|
3383
|
+
if (refCount <= 0) {
|
|
3384
|
+
this.artifacts.delete(artifactHash);
|
|
3385
|
+
return true;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
this.artifacts.set(artifactHash, { ...artifact, refCount });
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
2613
3393
|
const deployId = createDeployId();
|
|
2614
3394
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
2615
3395
|
let slug = createSlug();
|
|
2616
|
-
while (
|
|
3396
|
+
while (
|
|
3397
|
+
this.deploysBySlug.has(slug) ||
|
|
3398
|
+
(normalizedAppBaseDomain && this.deployDomainsByHostname.has(`${slug}.${normalizedAppBaseDomain}`))
|
|
3399
|
+
) {
|
|
2617
3400
|
slug = createSlug();
|
|
2618
3401
|
}
|
|
2619
3402
|
|
|
2620
3403
|
const token = createClaimToken();
|
|
2621
3404
|
const createdAt = now();
|
|
2622
|
-
const
|
|
2623
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
3405
|
+
const expiresAt = anonymousDeployExpiresAt();
|
|
2624
3406
|
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
2625
3407
|
|
|
2626
3408
|
this.storeArtifact({
|
|
@@ -2665,7 +3447,6 @@ export class MemoryAnonymousStore {
|
|
|
2665
3447
|
clientBundleHash,
|
|
2666
3448
|
deployId,
|
|
2667
3449
|
publicRootUrl,
|
|
2668
|
-
requestedTtlSeconds,
|
|
2669
3450
|
serverEnv
|
|
2670
3451
|
}) {
|
|
2671
3452
|
const currentDeploy = await this.getStoredDeployById(deployId);
|
|
@@ -2674,7 +3455,6 @@ export class MemoryAnonymousStore {
|
|
|
2674
3455
|
}
|
|
2675
3456
|
|
|
2676
3457
|
const updatedAt = now();
|
|
2677
|
-
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
2678
3458
|
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
2679
3459
|
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
2680
3460
|
const deploy = {
|
|
@@ -2682,7 +3462,7 @@ export class MemoryAnonymousStore {
|
|
|
2682
3462
|
appBaseDomain: nextAppBaseDomain,
|
|
2683
3463
|
artifactHash,
|
|
2684
3464
|
clientBundleHash,
|
|
2685
|
-
expiresAt: currentDeploy.ownerId ? null :
|
|
3465
|
+
expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
|
|
2686
3466
|
publicRootUrl: nextPublicRootUrl,
|
|
2687
3467
|
url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
|
|
2688
3468
|
status: "active",
|
|
@@ -2697,6 +3477,7 @@ export class MemoryAnonymousStore {
|
|
|
2697
3477
|
createdAt: updatedAt
|
|
2698
3478
|
});
|
|
2699
3479
|
this.deploys.set(deployId, deploy);
|
|
3480
|
+
this.decrementArtifactRef(currentDeploy.artifactHash);
|
|
2700
3481
|
if (serverEnv !== undefined) {
|
|
2701
3482
|
this.serverEnv.set(deployId, { ...serverEnv });
|
|
2702
3483
|
}
|
|
@@ -2755,6 +3536,62 @@ export class MemoryAnonymousStore {
|
|
|
2755
3536
|
return id ? this.getDeployById(id) : null;
|
|
2756
3537
|
}
|
|
2757
3538
|
|
|
3539
|
+
async getDeployDomainByHostname(hostname) {
|
|
3540
|
+
const domain = this.deployDomainsByHostname.get(hostname);
|
|
3541
|
+
return domain ? { ...domain } : null;
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
async listDeployDomainsForDeploy(deployId) {
|
|
3545
|
+
return Array.from(this.deployDomainsByHostname.values())
|
|
3546
|
+
.filter((domain) => domain.deployId === deployId)
|
|
3547
|
+
.sort((left, right) => String(left.hostname).localeCompare(String(right.hostname)))
|
|
3548
|
+
.map((domain) => ({ ...domain }));
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
|
|
3552
|
+
const deploy = await this.getStoredDeployById(deployId);
|
|
3553
|
+
if (!deploy) {
|
|
3554
|
+
return { status: "missing" };
|
|
3555
|
+
}
|
|
3556
|
+
if (!deploy.ownerId) {
|
|
3557
|
+
return { deploy, status: "unclaimed" };
|
|
3558
|
+
}
|
|
3559
|
+
if (deploy.ownerId !== ownerId) {
|
|
3560
|
+
return { deploy, status: "forbidden" };
|
|
3561
|
+
}
|
|
3562
|
+
if (deploy.status !== "active" || isExpired(deploy)) {
|
|
3563
|
+
return { deploy, status: "inactive" };
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
const existingDomain = this.deployDomainsByHostname.get(hostname);
|
|
3567
|
+
if (existingDomain) {
|
|
3568
|
+
if (existingDomain.deployId === deployId) {
|
|
3569
|
+
return { deploy, domain: { ...existingDomain }, status: "exists" };
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
return { deploy, domain: { ...existingDomain }, status: "conflict" };
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
if (this.deploysBySlug.has(label)) {
|
|
3576
|
+
return { deploy, status: "slug_conflict" };
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
const timestamp = now();
|
|
3580
|
+
const domain = {
|
|
3581
|
+
createdAt: timestamp,
|
|
3582
|
+
deployId,
|
|
3583
|
+
hostname,
|
|
3584
|
+
isPrimary: false,
|
|
3585
|
+
kind: "lakebed_subdomain",
|
|
3586
|
+
ownerId,
|
|
3587
|
+
status: "active",
|
|
3588
|
+
updatedAt: timestamp,
|
|
3589
|
+
url: `https://${hostname}`
|
|
3590
|
+
};
|
|
3591
|
+
this.deployDomainsByHostname.set(hostname, domain);
|
|
3592
|
+
return { deploy, domain: { ...domain }, status: "created" };
|
|
3593
|
+
}
|
|
3594
|
+
|
|
2758
3595
|
async getArtifact(hash) {
|
|
2759
3596
|
return this.artifacts.get(hash) ?? null;
|
|
2760
3597
|
}
|
|
@@ -2804,8 +3641,45 @@ export class MemoryAnonymousStore {
|
|
|
2804
3641
|
return { ...(this.serverEnv.get(deployId) ?? {}) };
|
|
2805
3642
|
}
|
|
2806
3643
|
|
|
2807
|
-
|
|
2808
|
-
const
|
|
3644
|
+
snapshotRowsForDeploy(deployId) {
|
|
3645
|
+
const snapshot = new Map();
|
|
3646
|
+
for (const [key, rows] of this.rows) {
|
|
3647
|
+
const [eventDeployId] = key.split(":");
|
|
3648
|
+
if (eventDeployId === deployId) {
|
|
3649
|
+
snapshot.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
return snapshot;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
restoreRowsForDeploy(deployId, snapshot) {
|
|
3656
|
+
for (const key of Array.from(this.rows.keys())) {
|
|
3657
|
+
const [eventDeployId] = key.split(":");
|
|
3658
|
+
if (eventDeployId === deployId) {
|
|
3659
|
+
this.rows.delete(key);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
for (const [key, rows] of snapshot) {
|
|
3663
|
+
this.rows.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
assertStateWithinLimits(deployId, options) {
|
|
3668
|
+
assertStateResourceLimits(this.stateResourceForDeploy(deployId), options);
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
async transaction(deployId, handler, options = {}) {
|
|
3672
|
+
const run = async () => {
|
|
3673
|
+
const snapshot = this.snapshotRowsForDeploy(deployId);
|
|
3674
|
+
try {
|
|
3675
|
+
const result = await handler(this);
|
|
3676
|
+
this.assertStateWithinLimits(deployId, options);
|
|
3677
|
+
return result;
|
|
3678
|
+
} catch (error) {
|
|
3679
|
+
this.restoreRowsForDeploy(deployId, snapshot);
|
|
3680
|
+
throw error;
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
2809
3683
|
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
2810
3684
|
this.queues.set(
|
|
2811
3685
|
deployId,
|
|
@@ -2818,9 +3692,13 @@ export class MemoryAnonymousStore {
|
|
|
2818
3692
|
}
|
|
2819
3693
|
|
|
2820
3694
|
async appendLog(deployId, level, message, data) {
|
|
2821
|
-
const entries = this.logs.get(deployId) ?? [];
|
|
2822
|
-
|
|
2823
|
-
|
|
3695
|
+
const entries = [...(this.logs.get(deployId) ?? []), normalizeLogEntry(level, message, data)];
|
|
3696
|
+
const maxEntries = DEFAULT_ANONYMOUS_LIMITS.logEntries;
|
|
3697
|
+
const maxBytes = DEFAULT_ANONYMOUS_LIMITS.logBytes;
|
|
3698
|
+
while (entries.length > maxEntries || bytesOfJson(entries) > maxBytes) {
|
|
3699
|
+
entries.shift();
|
|
3700
|
+
}
|
|
3701
|
+
this.logs.set(deployId, entries);
|
|
2824
3702
|
}
|
|
2825
3703
|
|
|
2826
3704
|
async readLogs(deployId, limit = 100) {
|
|
@@ -2856,12 +3734,67 @@ export class MemoryAnonymousStore {
|
|
|
2856
3734
|
}
|
|
2857
3735
|
|
|
2858
3736
|
async incrementQuota(deployId, bucket, limit) {
|
|
3737
|
+
if (!Number.isFinite(limit)) {
|
|
3738
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3739
|
+
}
|
|
3740
|
+
|
|
2859
3741
|
const windowStart = dayWindowStart();
|
|
2860
3742
|
const key = `${deployId}:${bucket}:${windowStart}`;
|
|
2861
3743
|
const count = (this.quotaEvents.get(key) ?? 0) + 1;
|
|
2862
3744
|
this.quotaEvents.set(key, count);
|
|
2863
3745
|
if (count > limit) {
|
|
2864
|
-
throw new
|
|
3746
|
+
throw new LakebedQuotaError({
|
|
3747
|
+
bucket,
|
|
3748
|
+
count,
|
|
3749
|
+
limit,
|
|
3750
|
+
resetAt: quotaResetAt(windowStart),
|
|
3751
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3752
|
+
suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
return { bucket, count, limit, windowStart };
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
async incrementDeployCreateQuota(clientKey, bucket, limit) {
|
|
3759
|
+
if (!Number.isFinite(limit)) {
|
|
3760
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
const windowStart = dayWindowStart();
|
|
3764
|
+
const key = JSON.stringify([clientKey, bucket, windowStart]);
|
|
3765
|
+
const count = (this.deployCreateQuotaEvents.get(key) ?? 0) + 1;
|
|
3766
|
+
this.deployCreateQuotaEvents.set(key, count);
|
|
3767
|
+
if (count > limit) {
|
|
3768
|
+
throw new LakebedQuotaError({
|
|
3769
|
+
bucket,
|
|
3770
|
+
count,
|
|
3771
|
+
limit,
|
|
3772
|
+
resetAt: quotaResetAt(windowStart),
|
|
3773
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3774
|
+
suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
|
|
3775
|
+
});
|
|
3776
|
+
}
|
|
3777
|
+
return { bucket, count, limit, windowStart };
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
async incrementClientQuota(clientKey, bucket, limit) {
|
|
3781
|
+
if (!Number.isFinite(limit)) {
|
|
3782
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
const windowStart = dayWindowStart();
|
|
3786
|
+
const key = JSON.stringify([clientKey, bucket, windowStart]);
|
|
3787
|
+
const count = (this.clientQuotaEvents.get(key) ?? 0) + 1;
|
|
3788
|
+
this.clientQuotaEvents.set(key, count);
|
|
3789
|
+
if (count > limit) {
|
|
3790
|
+
throw new LakebedQuotaError({
|
|
3791
|
+
bucket,
|
|
3792
|
+
count,
|
|
3793
|
+
limit,
|
|
3794
|
+
resetAt: quotaResetAt(windowStart),
|
|
3795
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3796
|
+
suggestion: clientTrafficQuotaSuggestion(bucket)
|
|
3797
|
+
});
|
|
2865
3798
|
}
|
|
2866
3799
|
return { bucket, count, limit, windowStart };
|
|
2867
3800
|
}
|
|
@@ -2947,6 +3880,160 @@ export class MemoryAnonymousStore {
|
|
|
2947
3880
|
|
|
2948
3881
|
return summaries;
|
|
2949
3882
|
}
|
|
3883
|
+
|
|
3884
|
+
deleteDeployResources(deployId) {
|
|
3885
|
+
const deploy = this.deploys.get(deployId);
|
|
3886
|
+
if (!deploy) {
|
|
3887
|
+
return {
|
|
3888
|
+
deletedArtifacts: 0,
|
|
3889
|
+
deletedDomains: 0,
|
|
3890
|
+
deletedLogEntries: 0,
|
|
3891
|
+
deletedQuotaEvents: 0,
|
|
3892
|
+
deletedServerEnv: 0,
|
|
3893
|
+
deletedStateRows: 0
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
let deletedStateRows = 0;
|
|
3898
|
+
for (const [key, rows] of Array.from(this.rows.entries())) {
|
|
3899
|
+
const [eventDeployId] = key.split(":");
|
|
3900
|
+
if (eventDeployId === deployId) {
|
|
3901
|
+
deletedStateRows += rows.size;
|
|
3902
|
+
this.rows.delete(key);
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
const deletedLogEntries = this.logs.get(deployId)?.length ?? 0;
|
|
3907
|
+
this.logs.delete(deployId);
|
|
3908
|
+
|
|
3909
|
+
let deletedQuotaEvents = 0;
|
|
3910
|
+
for (const key of Array.from(this.quotaEvents.keys())) {
|
|
3911
|
+
const [eventDeployId] = key.split(":");
|
|
3912
|
+
if (eventDeployId === deployId) {
|
|
3913
|
+
deletedQuotaEvents += 1;
|
|
3914
|
+
this.quotaEvents.delete(key);
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
const deletedServerEnv = this.serverEnv.has(deployId) ? Object.keys(this.serverEnv.get(deployId) ?? {}).length : 0;
|
|
3919
|
+
this.serverEnv.delete(deployId);
|
|
3920
|
+
|
|
3921
|
+
let deletedDomains = 0;
|
|
3922
|
+
for (const [hostname, domain] of Array.from(this.deployDomainsByHostname.entries())) {
|
|
3923
|
+
if (domain.deployId === deployId) {
|
|
3924
|
+
this.deployDomainsByHostname.delete(hostname);
|
|
3925
|
+
deletedDomains += 1;
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
this.deploys.delete(deployId);
|
|
3930
|
+
this.deploysBySlug.delete(deploy.slug);
|
|
3931
|
+
const deletedArtifacts = this.decrementArtifactRef(deploy.artifactHash) ? 1 : 0;
|
|
3932
|
+
|
|
3933
|
+
return {
|
|
3934
|
+
deletedArtifacts,
|
|
3935
|
+
deletedDomains,
|
|
3936
|
+
deletedLogEntries,
|
|
3937
|
+
deletedQuotaEvents,
|
|
3938
|
+
deletedServerEnv,
|
|
3939
|
+
deletedStateRows
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
recordCleanupRun(stats) {
|
|
3944
|
+
this.cleanupRuns.push(stats);
|
|
3945
|
+
this.cleanupRuns = this.cleanupRuns.slice(-20);
|
|
3946
|
+
for (const [key, value] of Object.entries(stats)) {
|
|
3947
|
+
if (typeof value === "number") {
|
|
3948
|
+
this.cleanupTotals[key] = (this.cleanupTotals[key] ?? 0) + value;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
|
|
3954
|
+
const startedAt = new Date(nowMs).toISOString();
|
|
3955
|
+
const markCutoffMs = nowMs - graceSeconds * 1000;
|
|
3956
|
+
const deleteCutoffMs = nowMs - retentionSeconds * 1000;
|
|
3957
|
+
const stats = {
|
|
3958
|
+
deletedArtifacts: 0,
|
|
3959
|
+
deletedDomains: 0,
|
|
3960
|
+
deletedDeploys: 0,
|
|
3961
|
+
deletedLogEntries: 0,
|
|
3962
|
+
deletedQuotaEvents: 0,
|
|
3963
|
+
deletedServerEnv: 0,
|
|
3964
|
+
deletedStateRows: 0,
|
|
3965
|
+
examinedDeploys: this.deploys.size,
|
|
3966
|
+
finishedAt: null,
|
|
3967
|
+
markedTerminated: 0,
|
|
3968
|
+
startedAt
|
|
3969
|
+
};
|
|
3970
|
+
|
|
3971
|
+
for (const deploy of Array.from(this.deploys.values())) {
|
|
3972
|
+
if (deploy.ownerId || !deploy.expiresAt) {
|
|
3973
|
+
continue;
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
const expiresAtMs = Date.parse(deploy.expiresAt);
|
|
3977
|
+
if (Number.isFinite(expiresAtMs) && expiresAtMs <= markCutoffMs && deploy.status === "active") {
|
|
3978
|
+
this.deploys.set(deploy.id, { ...deploy, status: "terminated", updatedAt: new Date(nowMs).toISOString() });
|
|
3979
|
+
stats.markedTerminated += 1;
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
for (const deploy of Array.from(this.deploys.values())) {
|
|
3984
|
+
if (deploy.ownerId || !deploy.expiresAt) {
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
const expiresAtMs = Date.parse(deploy.expiresAt);
|
|
3989
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs > deleteCutoffMs) {
|
|
3990
|
+
continue;
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
const deleted = this.deleteDeployResources(deploy.id);
|
|
3994
|
+
stats.deletedDeploys += 1;
|
|
3995
|
+
for (const [key, value] of Object.entries(deleted)) {
|
|
3996
|
+
stats[key] += value;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
for (const [key] of Array.from(this.deployCreateQuotaEvents.entries())) {
|
|
4001
|
+
try {
|
|
4002
|
+
const [, , windowStart] = JSON.parse(key);
|
|
4003
|
+
if (Date.parse(windowStart) <= deleteCutoffMs) {
|
|
4004
|
+
this.deployCreateQuotaEvents.delete(key);
|
|
4005
|
+
stats.deletedQuotaEvents += 1;
|
|
4006
|
+
}
|
|
4007
|
+
} catch {
|
|
4008
|
+
this.deployCreateQuotaEvents.delete(key);
|
|
4009
|
+
stats.deletedQuotaEvents += 1;
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
for (const [key] of Array.from(this.clientQuotaEvents.entries())) {
|
|
4013
|
+
try {
|
|
4014
|
+
const [, , windowStart] = JSON.parse(key);
|
|
4015
|
+
if (Date.parse(windowStart) <= deleteCutoffMs) {
|
|
4016
|
+
this.clientQuotaEvents.delete(key);
|
|
4017
|
+
stats.deletedQuotaEvents += 1;
|
|
4018
|
+
}
|
|
4019
|
+
} catch {
|
|
4020
|
+
this.clientQuotaEvents.delete(key);
|
|
4021
|
+
stats.deletedQuotaEvents += 1;
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
stats.finishedAt = now();
|
|
4026
|
+
this.recordCleanupRun(stats);
|
|
4027
|
+
return stats;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
async readCleanupActivity() {
|
|
4031
|
+
return {
|
|
4032
|
+
lastRun: this.cleanupRuns[this.cleanupRuns.length - 1] ?? null,
|
|
4033
|
+
recentRuns: [...this.cleanupRuns].reverse(),
|
|
4034
|
+
totals: { ...this.cleanupTotals }
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
2950
4037
|
}
|
|
2951
4038
|
|
|
2952
4039
|
export class PostgresAnonymousStore {
|
|
@@ -2989,6 +4076,20 @@ export class PostgresAnonymousStore {
|
|
|
2989
4076
|
await this.query("alter table deploys alter column updated_at set not null");
|
|
2990
4077
|
await this.query("alter table deploys alter column expires_at drop not null");
|
|
2991
4078
|
await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
|
|
4079
|
+
await this.query(`
|
|
4080
|
+
create table if not exists deploy_domains(
|
|
4081
|
+
hostname text primary key,
|
|
4082
|
+
deploy_id text not null references deploys(id) on delete cascade,
|
|
4083
|
+
owner_id text not null,
|
|
4084
|
+
kind text not null,
|
|
4085
|
+
status text not null,
|
|
4086
|
+
is_primary boolean not null default false,
|
|
4087
|
+
created_at timestamptz not null,
|
|
4088
|
+
updated_at timestamptz not null
|
|
4089
|
+
)
|
|
4090
|
+
`);
|
|
4091
|
+
await this.query("create index if not exists deploy_domains_deploy_id_idx on deploy_domains(deploy_id)");
|
|
4092
|
+
await this.query("create index if not exists deploy_domains_owner_id_idx on deploy_domains(owner_id)");
|
|
2992
4093
|
await this.query(`
|
|
2993
4094
|
create table if not exists artifacts(
|
|
2994
4095
|
hash text primary key,
|
|
@@ -3030,6 +4131,24 @@ export class PostgresAnonymousStore {
|
|
|
3030
4131
|
primary key (deploy_id, bucket, window_start)
|
|
3031
4132
|
)
|
|
3032
4133
|
`);
|
|
4134
|
+
await this.query(`
|
|
4135
|
+
create table if not exists deploy_create_quota_events(
|
|
4136
|
+
client_key text not null,
|
|
4137
|
+
bucket text not null,
|
|
4138
|
+
window_start timestamptz not null,
|
|
4139
|
+
count integer not null,
|
|
4140
|
+
primary key (client_key, bucket, window_start)
|
|
4141
|
+
)
|
|
4142
|
+
`);
|
|
4143
|
+
await this.query(`
|
|
4144
|
+
create table if not exists client_quota_events(
|
|
4145
|
+
client_key text not null,
|
|
4146
|
+
bucket text not null,
|
|
4147
|
+
window_start timestamptz not null,
|
|
4148
|
+
count integer not null,
|
|
4149
|
+
primary key (client_key, bucket, window_start)
|
|
4150
|
+
)
|
|
4151
|
+
`);
|
|
3033
4152
|
await this.query(`
|
|
3034
4153
|
create table if not exists deploy_server_env(
|
|
3035
4154
|
deploy_id text not null references deploys(id) on delete cascade,
|
|
@@ -3054,6 +4173,14 @@ export class PostgresAnonymousStore {
|
|
|
3054
4173
|
updated_at timestamptz not null
|
|
3055
4174
|
)
|
|
3056
4175
|
`);
|
|
4176
|
+
await this.query(`
|
|
4177
|
+
create table if not exists cleanup_runs(
|
|
4178
|
+
id bigserial primary key,
|
|
4179
|
+
started_at timestamptz not null,
|
|
4180
|
+
finished_at timestamptz not null,
|
|
4181
|
+
stats_json jsonb not null
|
|
4182
|
+
)
|
|
4183
|
+
`);
|
|
3057
4184
|
await this.query("alter table users add column if not exists boost boolean not null default false");
|
|
3058
4185
|
await this.query(`
|
|
3059
4186
|
insert into users(
|
|
@@ -3094,6 +4221,33 @@ export class PostgresAnonymousStore {
|
|
|
3094
4221
|
return this.pool.query(sql, params);
|
|
3095
4222
|
}
|
|
3096
4223
|
|
|
4224
|
+
async withPostgresTransaction(handler) {
|
|
4225
|
+
if (!this.pool) {
|
|
4226
|
+
throw new Error("Postgres store is not initialized.");
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
const client = await this.pool.connect();
|
|
4230
|
+
try {
|
|
4231
|
+
await client.query("begin");
|
|
4232
|
+
const result = await handler(client);
|
|
4233
|
+
await client.query("commit");
|
|
4234
|
+
return result;
|
|
4235
|
+
} catch (error) {
|
|
4236
|
+
try {
|
|
4237
|
+
await client.query("rollback");
|
|
4238
|
+
} catch {
|
|
4239
|
+
// Preserve the original transaction error.
|
|
4240
|
+
}
|
|
4241
|
+
throw error;
|
|
4242
|
+
} finally {
|
|
4243
|
+
client.release();
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
async lockHostnameAllocation(client, hostname) {
|
|
4248
|
+
await client.query("select pg_advisory_xact_lock(hashtext('lakebed.deploy_hostname'), hashtext($1))", [hostname]);
|
|
4249
|
+
}
|
|
4250
|
+
|
|
3097
4251
|
rowToUser(row) {
|
|
3098
4252
|
if (!row) {
|
|
3099
4253
|
return null;
|
|
@@ -3318,11 +4472,29 @@ export class PostgresAnonymousStore {
|
|
|
3318
4472
|
);
|
|
3319
4473
|
}
|
|
3320
4474
|
|
|
3321
|
-
async
|
|
4475
|
+
async decrementArtifactRef(artifactHash) {
|
|
4476
|
+
const result = await this.query(
|
|
4477
|
+
`
|
|
4478
|
+
update artifacts
|
|
4479
|
+
set ref_count = greatest(ref_count - 1, 0)
|
|
4480
|
+
where hash = $1
|
|
4481
|
+
returning ref_count
|
|
4482
|
+
`,
|
|
4483
|
+
[artifactHash]
|
|
4484
|
+
);
|
|
4485
|
+
const refCount = result.rows[0]?.ref_count;
|
|
4486
|
+
if (refCount === undefined || Number(refCount) > 0) {
|
|
4487
|
+
return false;
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
await this.query("delete from artifacts where hash = $1 and ref_count <= 0", [artifactHash]);
|
|
4491
|
+
return true;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
3322
4495
|
const createdAt = now();
|
|
3323
4496
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
3324
|
-
const
|
|
3325
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
4497
|
+
const expiresAt = anonymousDeployExpiresAt();
|
|
3326
4498
|
const token = createClaimToken();
|
|
3327
4499
|
const deployId = createDeployId();
|
|
3328
4500
|
|
|
@@ -3330,6 +4502,8 @@ export class PostgresAnonymousStore {
|
|
|
3330
4502
|
|
|
3331
4503
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
3332
4504
|
const slug = createSlug();
|
|
4505
|
+
const generatedHostname = normalizedAppBaseDomain ? `${slug}.${normalizedAppBaseDomain}` : null;
|
|
4506
|
+
|
|
3333
4507
|
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
3334
4508
|
const deploy = {
|
|
3335
4509
|
appBaseDomain: normalizedAppBaseDomain,
|
|
@@ -3351,30 +4525,44 @@ export class PostgresAnonymousStore {
|
|
|
3351
4525
|
};
|
|
3352
4526
|
|
|
3353
4527
|
try {
|
|
3354
|
-
await this.
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
4528
|
+
const inserted = await this.withPostgresTransaction(async (client) => {
|
|
4529
|
+
if (generatedHostname) {
|
|
4530
|
+
await this.lockHostnameAllocation(client, generatedHostname);
|
|
4531
|
+
const domainConflict = await client.query("select 1 from deploy_domains where hostname = $1", [generatedHostname]);
|
|
4532
|
+
if (domainConflict.rows.length > 0) {
|
|
4533
|
+
return false;
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
await client.query(
|
|
4538
|
+
`
|
|
4539
|
+
insert into deploys(
|
|
4540
|
+
id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
|
|
4541
|
+
claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
|
|
4542
|
+
)
|
|
4543
|
+
values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
|
|
4544
|
+
`,
|
|
4545
|
+
[
|
|
4546
|
+
deploy.id,
|
|
4547
|
+
deploy.slug,
|
|
4548
|
+
deploy.status,
|
|
4549
|
+
deploy.artifactHash,
|
|
4550
|
+
deploy.clientBundleHash,
|
|
4551
|
+
deploy.createdAt,
|
|
4552
|
+
deploy.updatedAt,
|
|
4553
|
+
deploy.expiresAt,
|
|
4554
|
+
deploy.claimTokenHash,
|
|
4555
|
+
JSON.stringify(deploy.limits),
|
|
4556
|
+
deploy.publicRootUrl,
|
|
4557
|
+
deploy.appBaseDomain,
|
|
4558
|
+
deploy.url
|
|
4559
|
+
]
|
|
4560
|
+
);
|
|
4561
|
+
return true;
|
|
4562
|
+
});
|
|
4563
|
+
if (!inserted) {
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
3378
4566
|
if (serverEnv !== undefined) {
|
|
3379
4567
|
await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
|
|
3380
4568
|
}
|
|
@@ -3397,7 +4585,6 @@ export class PostgresAnonymousStore {
|
|
|
3397
4585
|
clientBundleHash,
|
|
3398
4586
|
deployId,
|
|
3399
4587
|
publicRootUrl,
|
|
3400
|
-
requestedTtlSeconds,
|
|
3401
4588
|
serverEnv
|
|
3402
4589
|
}) {
|
|
3403
4590
|
const currentDeploy = await this.getBaseDeployById(deployId);
|
|
@@ -3406,8 +4593,7 @@ export class PostgresAnonymousStore {
|
|
|
3406
4593
|
}
|
|
3407
4594
|
|
|
3408
4595
|
const updatedAt = now();
|
|
3409
|
-
const
|
|
3410
|
-
const expiresAt = currentDeploy.ownerId ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
4596
|
+
const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
|
|
3411
4597
|
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
3412
4598
|
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
3413
4599
|
const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
|
|
@@ -3432,6 +4618,7 @@ export class PostgresAnonymousStore {
|
|
|
3432
4618
|
if (serverEnv !== undefined) {
|
|
3433
4619
|
await this.replaceServerEnv(deployId, serverEnv, updatedAt);
|
|
3434
4620
|
}
|
|
4621
|
+
await this.decrementArtifactRef(currentDeploy.artifactHash);
|
|
3435
4622
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3436
4623
|
}
|
|
3437
4624
|
|
|
@@ -3500,7 +4687,7 @@ export class PostgresAnonymousStore {
|
|
|
3500
4687
|
createdAt: new Date(row.created_at).toISOString(),
|
|
3501
4688
|
expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
|
|
3502
4689
|
id: row.id,
|
|
3503
|
-
limits: row.limits_json,
|
|
4690
|
+
limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
|
|
3504
4691
|
owner: row.owner_json ?? null,
|
|
3505
4692
|
ownerId: row.owner_id ?? null,
|
|
3506
4693
|
publicRootUrl: row.public_root_url,
|
|
@@ -3511,6 +4698,24 @@ export class PostgresAnonymousStore {
|
|
|
3511
4698
|
};
|
|
3512
4699
|
}
|
|
3513
4700
|
|
|
4701
|
+
rowToDeployDomain(row) {
|
|
4702
|
+
if (!row) {
|
|
4703
|
+
return null;
|
|
4704
|
+
}
|
|
4705
|
+
|
|
4706
|
+
return {
|
|
4707
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
4708
|
+
deployId: row.deploy_id,
|
|
4709
|
+
hostname: row.hostname,
|
|
4710
|
+
isPrimary: Boolean(row.is_primary),
|
|
4711
|
+
kind: row.kind,
|
|
4712
|
+
ownerId: row.owner_id,
|
|
4713
|
+
status: row.status,
|
|
4714
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
4715
|
+
url: `https://${row.hostname}`
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
|
|
3514
4719
|
async getDeployById(id) {
|
|
3515
4720
|
return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
|
|
3516
4721
|
}
|
|
@@ -3520,6 +4725,80 @@ export class PostgresAnonymousStore {
|
|
|
3520
4725
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3521
4726
|
}
|
|
3522
4727
|
|
|
4728
|
+
async getDeployDomainByHostname(hostname) {
|
|
4729
|
+
const result = await this.query("select * from deploy_domains where hostname = $1", [hostname]);
|
|
4730
|
+
return this.rowToDeployDomain(result.rows[0]);
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
async listDeployDomainsForDeploy(deployId) {
|
|
4734
|
+
const result = await this.query("select * from deploy_domains where deploy_id = $1 order by hostname asc", [deployId]);
|
|
4735
|
+
return result.rows.map((row) => this.rowToDeployDomain(row));
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
|
|
4739
|
+
try {
|
|
4740
|
+
return await this.withPostgresTransaction(async (client) => {
|
|
4741
|
+
const deployResult = await client.query("select * from deploys where id = $1 for update", [deployId]);
|
|
4742
|
+
const deploy = this.rowToDeploy(deployResult.rows[0]);
|
|
4743
|
+
if (!deploy) {
|
|
4744
|
+
return { status: "missing" };
|
|
4745
|
+
}
|
|
4746
|
+
if (!deploy.ownerId) {
|
|
4747
|
+
return { deploy, status: "unclaimed" };
|
|
4748
|
+
}
|
|
4749
|
+
if (deploy.ownerId !== ownerId) {
|
|
4750
|
+
return { deploy, status: "forbidden" };
|
|
4751
|
+
}
|
|
4752
|
+
if (deploy.status !== "active" || isExpired(deploy)) {
|
|
4753
|
+
return { deploy, status: "inactive" };
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
await this.lockHostnameAllocation(client, hostname);
|
|
4757
|
+
|
|
4758
|
+
const existingResult = await client.query("select * from deploy_domains where hostname = $1", [hostname]);
|
|
4759
|
+
const existing = this.rowToDeployDomain(existingResult.rows[0]);
|
|
4760
|
+
if (existing) {
|
|
4761
|
+
if (existing.deployId === deployId) {
|
|
4762
|
+
return { deploy, domain: existing, status: "exists" };
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
return { deploy, domain: existing, status: "conflict" };
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
const slugConflict = await client.query("select id from deploys where slug = $1 limit 1", [label]);
|
|
4769
|
+
if (slugConflict.rows.length > 0) {
|
|
4770
|
+
return { deploy, status: "slug_conflict" };
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
const timestamp = now();
|
|
4774
|
+
const insertResult = await client.query(
|
|
4775
|
+
`
|
|
4776
|
+
insert into deploy_domains(hostname, deploy_id, owner_id, kind, status, is_primary, created_at, updated_at)
|
|
4777
|
+
values($1, $2, $3, 'lakebed_subdomain', 'active', false, $4, $4)
|
|
4778
|
+
returning *
|
|
4779
|
+
`,
|
|
4780
|
+
[hostname, deployId, ownerId, timestamp]
|
|
4781
|
+
);
|
|
4782
|
+
return { deploy, domain: this.rowToDeployDomain(insertResult.rows[0]), status: "created" };
|
|
4783
|
+
});
|
|
4784
|
+
} catch (error) {
|
|
4785
|
+
if (error?.code !== "23505") {
|
|
4786
|
+
throw error;
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
const deploy = await this.getBaseDeployById(deployId);
|
|
4790
|
+
if (!deploy) {
|
|
4791
|
+
return { status: "missing" };
|
|
4792
|
+
}
|
|
4793
|
+
const conflict = await this.getDeployDomainByHostname(hostname);
|
|
4794
|
+
if (conflict?.deployId === deployId) {
|
|
4795
|
+
return { deploy, domain: conflict, status: "exists" };
|
|
4796
|
+
}
|
|
4797
|
+
|
|
4798
|
+
return { deploy, domain: conflict, status: "conflict" };
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
|
|
3523
4802
|
async getArtifact(hash) {
|
|
3524
4803
|
const result = await this.query("select * from artifacts where hash = $1", [hash]);
|
|
3525
4804
|
const row = result.rows[0];
|
|
@@ -3600,8 +4879,46 @@ export class PostgresAnonymousStore {
|
|
|
3600
4879
|
return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
|
|
3601
4880
|
}
|
|
3602
4881
|
|
|
3603
|
-
async
|
|
3604
|
-
const
|
|
4882
|
+
async stateResourceForDeploy(deployId) {
|
|
4883
|
+
const result = await this.query(
|
|
4884
|
+
`
|
|
4885
|
+
select
|
|
4886
|
+
count(*)::int as state_rows,
|
|
4887
|
+
coalesce(sum(octet_length(data_json::text)), 0)::int as state_bytes
|
|
4888
|
+
from state_rows
|
|
4889
|
+
where deploy_id = $1
|
|
4890
|
+
`,
|
|
4891
|
+
[deployId]
|
|
4892
|
+
);
|
|
4893
|
+
return {
|
|
4894
|
+
stateBytes: result.rows[0]?.state_bytes ?? 0,
|
|
4895
|
+
stateRows: result.rows[0]?.state_rows ?? 0
|
|
4896
|
+
};
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
async assertStateWithinLimits(deployId, options) {
|
|
4900
|
+
assertStateResourceLimits(await this.stateResourceForDeploy(deployId), options);
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
async transaction(deployId, handler, options = {}) {
|
|
4904
|
+
const run = async () => {
|
|
4905
|
+
const client = await this.pool.connect();
|
|
4906
|
+
const tx = Object.create(this);
|
|
4907
|
+
tx.query = (sql, params = []) => client.query(sql, params);
|
|
4908
|
+
tx.pool = this.pool;
|
|
4909
|
+
try {
|
|
4910
|
+
await client.query("begin");
|
|
4911
|
+
const result = await handler(tx);
|
|
4912
|
+
await tx.assertStateWithinLimits(deployId, options);
|
|
4913
|
+
await client.query("commit");
|
|
4914
|
+
return result;
|
|
4915
|
+
} catch (error) {
|
|
4916
|
+
await client.query("rollback").catch(() => undefined);
|
|
4917
|
+
throw error;
|
|
4918
|
+
} finally {
|
|
4919
|
+
client.release();
|
|
4920
|
+
}
|
|
4921
|
+
};
|
|
3605
4922
|
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
3606
4923
|
this.queues.set(
|
|
3607
4924
|
deployId,
|
|
@@ -3614,9 +4931,29 @@ export class PostgresAnonymousStore {
|
|
|
3614
4931
|
}
|
|
3615
4932
|
|
|
3616
4933
|
async appendLog(deployId, level, message, data) {
|
|
4934
|
+
const entry = normalizeLogEntry(level, message, data);
|
|
3617
4935
|
await this.query(
|
|
3618
4936
|
"insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
|
|
3619
|
-
[deployId, level, message, JSON.stringify(data ?? null),
|
|
4937
|
+
[deployId, entry.level, entry.message, JSON.stringify(entry.data ?? null), entry.at]
|
|
4938
|
+
);
|
|
4939
|
+
await this.query(
|
|
4940
|
+
`
|
|
4941
|
+
delete from logs
|
|
4942
|
+
where deploy_id = $1
|
|
4943
|
+
and sequence in (
|
|
4944
|
+
select sequence
|
|
4945
|
+
from (
|
|
4946
|
+
select
|
|
4947
|
+
sequence,
|
|
4948
|
+
row_number() over (order by sequence desc) as row_number,
|
|
4949
|
+
sum(octet_length(message) + octet_length(coalesce(data_json::text, ''))) over (order by sequence desc) as cumulative_bytes
|
|
4950
|
+
from logs
|
|
4951
|
+
where deploy_id = $1
|
|
4952
|
+
) ranked
|
|
4953
|
+
where row_number > $2 or cumulative_bytes > $3
|
|
4954
|
+
)
|
|
4955
|
+
`,
|
|
4956
|
+
[deployId, DEFAULT_ANONYMOUS_LIMITS.logEntries, DEFAULT_ANONYMOUS_LIMITS.logBytes]
|
|
3620
4957
|
);
|
|
3621
4958
|
}
|
|
3622
4959
|
|
|
@@ -3669,6 +5006,10 @@ export class PostgresAnonymousStore {
|
|
|
3669
5006
|
}
|
|
3670
5007
|
|
|
3671
5008
|
async incrementQuota(deployId, bucket, limit) {
|
|
5009
|
+
if (!Number.isFinite(limit)) {
|
|
5010
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5011
|
+
}
|
|
5012
|
+
|
|
3672
5013
|
const windowStart = dayWindowStart();
|
|
3673
5014
|
const result = await this.query(
|
|
3674
5015
|
`
|
|
@@ -3682,7 +5023,74 @@ export class PostgresAnonymousStore {
|
|
|
3682
5023
|
);
|
|
3683
5024
|
const count = result.rows[0].count;
|
|
3684
5025
|
if (count > limit) {
|
|
3685
|
-
throw new
|
|
5026
|
+
throw new LakebedQuotaError({
|
|
5027
|
+
bucket,
|
|
5028
|
+
count,
|
|
5029
|
+
limit,
|
|
5030
|
+
resetAt: quotaResetAt(windowStart),
|
|
5031
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5032
|
+
suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
|
|
5033
|
+
});
|
|
5034
|
+
}
|
|
5035
|
+
return { bucket, count, limit, windowStart };
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
async incrementDeployCreateQuota(clientKey, bucket, limit) {
|
|
5039
|
+
if (!Number.isFinite(limit)) {
|
|
5040
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
const windowStart = dayWindowStart();
|
|
5044
|
+
const result = await this.query(
|
|
5045
|
+
`
|
|
5046
|
+
insert into deploy_create_quota_events(client_key, bucket, window_start, count)
|
|
5047
|
+
values($1, $2, $3, 1)
|
|
5048
|
+
on conflict(client_key, bucket, window_start)
|
|
5049
|
+
do update set count = deploy_create_quota_events.count + 1
|
|
5050
|
+
returning count
|
|
5051
|
+
`,
|
|
5052
|
+
[clientKey, bucket, windowStart]
|
|
5053
|
+
);
|
|
5054
|
+
const count = result.rows[0].count;
|
|
5055
|
+
if (count > limit) {
|
|
5056
|
+
throw new LakebedQuotaError({
|
|
5057
|
+
bucket,
|
|
5058
|
+
count,
|
|
5059
|
+
limit,
|
|
5060
|
+
resetAt: quotaResetAt(windowStart),
|
|
5061
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5062
|
+
suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
5065
|
+
return { bucket, count, limit, windowStart };
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
async incrementClientQuota(clientKey, bucket, limit) {
|
|
5069
|
+
if (!Number.isFinite(limit)) {
|
|
5070
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5071
|
+
}
|
|
5072
|
+
|
|
5073
|
+
const windowStart = dayWindowStart();
|
|
5074
|
+
const result = await this.query(
|
|
5075
|
+
`
|
|
5076
|
+
insert into client_quota_events(client_key, bucket, window_start, count)
|
|
5077
|
+
values($1, $2, $3, 1)
|
|
5078
|
+
on conflict(client_key, bucket, window_start)
|
|
5079
|
+
do update set count = client_quota_events.count + 1
|
|
5080
|
+
returning count
|
|
5081
|
+
`,
|
|
5082
|
+
[clientKey, bucket, windowStart]
|
|
5083
|
+
);
|
|
5084
|
+
const count = result.rows[0].count;
|
|
5085
|
+
if (count > limit) {
|
|
5086
|
+
throw new LakebedQuotaError({
|
|
5087
|
+
bucket,
|
|
5088
|
+
count,
|
|
5089
|
+
limit,
|
|
5090
|
+
resetAt: quotaResetAt(windowStart),
|
|
5091
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5092
|
+
suggestion: clientTrafficQuotaSuggestion(bucket)
|
|
5093
|
+
});
|
|
3686
5094
|
}
|
|
3687
5095
|
return { bucket, count, limit, windowStart };
|
|
3688
5096
|
}
|
|
@@ -3796,6 +5204,109 @@ export class PostgresAnonymousStore {
|
|
|
3796
5204
|
})
|
|
3797
5205
|
);
|
|
3798
5206
|
}
|
|
5207
|
+
|
|
5208
|
+
async recordCleanupRun(stats) {
|
|
5209
|
+
await this.query(
|
|
5210
|
+
"insert into cleanup_runs(started_at, finished_at, stats_json) values($1, $2, $3::jsonb)",
|
|
5211
|
+
[stats.startedAt, stats.finishedAt, JSON.stringify(stats)]
|
|
5212
|
+
);
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
|
|
5216
|
+
const startedAt = new Date(nowMs).toISOString();
|
|
5217
|
+
const finishedAt = now();
|
|
5218
|
+
const markCutoff = new Date(nowMs - graceSeconds * 1000).toISOString();
|
|
5219
|
+
const deleteCutoff = new Date(nowMs - retentionSeconds * 1000).toISOString();
|
|
5220
|
+
const examined = await this.query("select count(*)::int as count from deploys");
|
|
5221
|
+
const marked = await this.query(
|
|
5222
|
+
`
|
|
5223
|
+
update deploys
|
|
5224
|
+
set status = 'terminated',
|
|
5225
|
+
updated_at = $2
|
|
5226
|
+
where owner_id is null
|
|
5227
|
+
and expires_at is not null
|
|
5228
|
+
and expires_at <= $1
|
|
5229
|
+
and status = 'active'
|
|
5230
|
+
returning id
|
|
5231
|
+
`,
|
|
5232
|
+
[markCutoff, finishedAt]
|
|
5233
|
+
);
|
|
5234
|
+
const candidates = await this.query(
|
|
5235
|
+
`
|
|
5236
|
+
select id, artifact_hash
|
|
5237
|
+
from deploys
|
|
5238
|
+
where owner_id is null
|
|
5239
|
+
and expires_at is not null
|
|
5240
|
+
and expires_at <= $1
|
|
5241
|
+
`,
|
|
5242
|
+
[deleteCutoff]
|
|
5243
|
+
);
|
|
5244
|
+
const stats = {
|
|
5245
|
+
deletedArtifacts: 0,
|
|
5246
|
+
deletedDeploys: 0,
|
|
5247
|
+
deletedLogEntries: 0,
|
|
5248
|
+
deletedQuotaEvents: 0,
|
|
5249
|
+
deletedServerEnv: 0,
|
|
5250
|
+
deletedStateRows: 0,
|
|
5251
|
+
examinedDeploys: examined.rows[0]?.count ?? 0,
|
|
5252
|
+
finishedAt,
|
|
5253
|
+
markedTerminated: marked.rowCount,
|
|
5254
|
+
startedAt
|
|
5255
|
+
};
|
|
5256
|
+
|
|
5257
|
+
for (const deploy of candidates.rows) {
|
|
5258
|
+
const stateRows = await this.query("delete from state_rows where deploy_id = $1", [deploy.id]);
|
|
5259
|
+
const logs = await this.query("delete from logs where deploy_id = $1", [deploy.id]);
|
|
5260
|
+
const quota = await this.query("delete from quota_events where deploy_id = $1", [deploy.id]);
|
|
5261
|
+
const serverEnv = await this.query("delete from deploy_server_env where deploy_id = $1", [deploy.id]);
|
|
5262
|
+
await this.query("delete from deploys where id = $1", [deploy.id]);
|
|
5263
|
+
stats.deletedDeploys += 1;
|
|
5264
|
+
stats.deletedStateRows += stateRows.rowCount;
|
|
5265
|
+
stats.deletedLogEntries += logs.rowCount;
|
|
5266
|
+
stats.deletedQuotaEvents += quota.rowCount;
|
|
5267
|
+
stats.deletedServerEnv += serverEnv.rowCount;
|
|
5268
|
+
stats.deletedArtifacts += (await this.decrementArtifactRef(deploy.artifact_hash)) ? 1 : 0;
|
|
5269
|
+
}
|
|
5270
|
+
const deployCreateQuota = await this.query("delete from deploy_create_quota_events where window_start <= $1", [deleteCutoff]);
|
|
5271
|
+
stats.deletedQuotaEvents += deployCreateQuota.rowCount;
|
|
5272
|
+
const clientQuota = await this.query("delete from client_quota_events where window_start <= $1", [deleteCutoff]);
|
|
5273
|
+
stats.deletedQuotaEvents += clientQuota.rowCount;
|
|
5274
|
+
|
|
5275
|
+
await this.recordCleanupRun(stats);
|
|
5276
|
+
return stats;
|
|
5277
|
+
}
|
|
5278
|
+
|
|
5279
|
+
async readCleanupActivity() {
|
|
5280
|
+
const result = await this.query("select stats_json from cleanup_runs order by id desc limit 20");
|
|
5281
|
+
const recentRuns = result.rows.map((row) => row.stats_json);
|
|
5282
|
+
const totalsResult = await this.query(`
|
|
5283
|
+
select
|
|
5284
|
+
coalesce(sum((stats_json->>'deletedArtifacts')::int), 0)::int as deleted_artifacts,
|
|
5285
|
+
coalesce(sum((stats_json->>'deletedDeploys')::int), 0)::int as deleted_deploys,
|
|
5286
|
+
coalesce(sum((stats_json->>'deletedLogEntries')::int), 0)::int as deleted_log_entries,
|
|
5287
|
+
coalesce(sum((stats_json->>'deletedQuotaEvents')::int), 0)::int as deleted_quota_events,
|
|
5288
|
+
coalesce(sum((stats_json->>'deletedServerEnv')::int), 0)::int as deleted_server_env,
|
|
5289
|
+
coalesce(sum((stats_json->>'deletedStateRows')::int), 0)::int as deleted_state_rows,
|
|
5290
|
+
coalesce(sum((stats_json->>'examinedDeploys')::int), 0)::int as examined_deploys,
|
|
5291
|
+
coalesce(sum((stats_json->>'markedTerminated')::int), 0)::int as marked_terminated
|
|
5292
|
+
from cleanup_runs
|
|
5293
|
+
`);
|
|
5294
|
+
const totalsRow = totalsResult.rows[0] ?? {};
|
|
5295
|
+
return {
|
|
5296
|
+
lastRun: recentRuns[0] ?? null,
|
|
5297
|
+
recentRuns,
|
|
5298
|
+
totals: {
|
|
5299
|
+
deletedArtifacts: totalsRow.deleted_artifacts ?? 0,
|
|
5300
|
+
deletedDeploys: totalsRow.deleted_deploys ?? 0,
|
|
5301
|
+
deletedLogEntries: totalsRow.deleted_log_entries ?? 0,
|
|
5302
|
+
deletedQuotaEvents: totalsRow.deleted_quota_events ?? 0,
|
|
5303
|
+
deletedServerEnv: totalsRow.deleted_server_env ?? 0,
|
|
5304
|
+
deletedStateRows: totalsRow.deleted_state_rows ?? 0,
|
|
5305
|
+
examinedDeploys: totalsRow.examined_deploys ?? 0,
|
|
5306
|
+
markedTerminated: totalsRow.marked_terminated ?? 0
|
|
5307
|
+
}
|
|
5308
|
+
};
|
|
5309
|
+
}
|
|
3799
5310
|
}
|
|
3800
5311
|
|
|
3801
5312
|
export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
@@ -3814,12 +5325,30 @@ export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
|
3814
5325
|
}
|
|
3815
5326
|
|
|
3816
5327
|
async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
|
|
3817
|
-
const
|
|
5328
|
+
const hostname = hostnameFromHost(host);
|
|
5329
|
+
const domain = hostname && typeof store.getDeployDomainByHostname === "function" ? await store.getDeployDomainByHostname(hostname) : null;
|
|
5330
|
+
const route = domain
|
|
5331
|
+
? {
|
|
5332
|
+
appPath: url.pathname || "/",
|
|
5333
|
+
basePath: "",
|
|
5334
|
+
domain,
|
|
5335
|
+
hostname: domain.hostname,
|
|
5336
|
+
deployId: domain.deployId
|
|
5337
|
+
}
|
|
5338
|
+
: parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
|
|
3818
5339
|
if (!route) {
|
|
3819
5340
|
return null;
|
|
3820
5341
|
}
|
|
3821
5342
|
|
|
3822
|
-
|
|
5343
|
+
if (domain?.status && domain.status !== "active") {
|
|
5344
|
+
return {
|
|
5345
|
+
error: domain.status === "disabled" ? "Lakebed subdomain is disabled." : "Lakebed subdomain is not active.",
|
|
5346
|
+
route,
|
|
5347
|
+
status: domain.status === "disabled" ? 410 : 404
|
|
5348
|
+
};
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
const deploy = route.deployId ? await store.getDeployById(route.deployId) : await store.getDeployBySlug(route.slug);
|
|
3823
5352
|
if (!deploy) {
|
|
3824
5353
|
return { error: "Unknown anonymous deploy.", route, status: 404 };
|
|
3825
5354
|
}
|
|
@@ -3846,6 +5375,10 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
|
|
|
3846
5375
|
artifactHash: deploy.artifactHash,
|
|
3847
5376
|
clientBundleHash: deploy.clientBundleHash,
|
|
3848
5377
|
deployId: deploy.id,
|
|
5378
|
+
domains:
|
|
5379
|
+
typeof store.listDeployDomainsForDeploy === "function"
|
|
5380
|
+
? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
|
|
5381
|
+
: [],
|
|
3849
5382
|
expiresAt: deploy.expiresAt,
|
|
3850
5383
|
limits: deploy.limits,
|
|
3851
5384
|
mutations: Object.keys(artifact.server.mutations ?? {}),
|
|
@@ -3911,6 +5444,9 @@ export async function startAnonymousServer({
|
|
|
3911
5444
|
} = {}) {
|
|
3912
5445
|
const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
3913
5446
|
const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
|
|
5447
|
+
const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
|
|
5448
|
+
const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
|
|
5449
|
+
const cleanupPolicy = cleanupPolicyFromEnv();
|
|
3914
5450
|
const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
|
|
3915
5451
|
const resolvedDeveloperSessionSecret =
|
|
3916
5452
|
developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
|
|
@@ -3918,6 +5454,7 @@ export async function startAnonymousServer({
|
|
|
3918
5454
|
const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
|
|
3919
5455
|
await resolvedStore.initialize();
|
|
3920
5456
|
const subscriptions = new Map();
|
|
5457
|
+
let cleanupInterval = null;
|
|
3921
5458
|
|
|
3922
5459
|
function activeConnectionCounts() {
|
|
3923
5460
|
const counts = new Map();
|
|
@@ -3938,6 +5475,10 @@ export async function startAnonymousServer({
|
|
|
3938
5475
|
async function adminSummary() {
|
|
3939
5476
|
const deploys = await adminDeploysWithConnections();
|
|
3940
5477
|
const users = await resolvedStore.listAdminUsers();
|
|
5478
|
+
const cleanup =
|
|
5479
|
+
typeof resolvedStore.readCleanupActivity === "function"
|
|
5480
|
+
? await resolvedStore.readCleanupActivity()
|
|
5481
|
+
: { lastRun: null, recentRuns: [], totals: {} };
|
|
3941
5482
|
const totals = deploys.reduce(
|
|
3942
5483
|
(acc, deploy) => ({
|
|
3943
5484
|
artifactBytes: acc.artifactBytes + deploy.artifactBytes,
|
|
@@ -3964,6 +5505,7 @@ export async function startAnonymousServer({
|
|
|
3964
5505
|
return {
|
|
3965
5506
|
deployCount: deploys.length,
|
|
3966
5507
|
deploys,
|
|
5508
|
+
cleanup,
|
|
3967
5509
|
generatedAt: now(),
|
|
3968
5510
|
totals,
|
|
3969
5511
|
userCount: users.length,
|
|
@@ -3990,10 +5532,50 @@ export async function startAnonymousServer({
|
|
|
3990
5532
|
return developerFromRequest(req, resolvedDeveloperSessionSecret);
|
|
3991
5533
|
}
|
|
3992
5534
|
|
|
5535
|
+
function canManageDeploy(req, deploy) {
|
|
5536
|
+
if (isDeployTokenValid(deploy, bearerToken(req))) {
|
|
5537
|
+
return true;
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
const user = currentDeveloper(req);
|
|
5541
|
+
return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
|
|
5542
|
+
}
|
|
5543
|
+
|
|
3993
5544
|
async function developerDeploys(user) {
|
|
3994
5545
|
return resolvedStore.listDeploysForOwner(user.id);
|
|
3995
5546
|
}
|
|
3996
5547
|
|
|
5548
|
+
async function enforceAnonymousDeployCreation(req) {
|
|
5549
|
+
if (deployCreationPolicy.disabled) {
|
|
5550
|
+
const error = new LakebedQuotaError({
|
|
5551
|
+
bucket: "anonymous_deploy_create_global",
|
|
5552
|
+
count: deployCreationPolicy.globalLimit,
|
|
5553
|
+
limit: deployCreationPolicy.globalLimit,
|
|
5554
|
+
status: 503,
|
|
5555
|
+
suggestion: "Anonymous deploy creation is temporarily disabled. Retry later or claim an existing deploy."
|
|
5556
|
+
});
|
|
5557
|
+
error.code = "lakebed_deploy_creation_disabled";
|
|
5558
|
+
error.message = "Anonymous deploy creation is temporarily disabled.";
|
|
5559
|
+
throw error;
|
|
5560
|
+
}
|
|
5561
|
+
|
|
5562
|
+
const clientKey = forwardedClientKey(req);
|
|
5563
|
+
await resolvedStore.incrementDeployCreateQuota(clientKey, "anonymous_deploy_create_client", deployCreationPolicy.perClientLimit);
|
|
5564
|
+
await resolvedStore.incrementDeployCreateQuota("global", "anonymous_deploy_create_global", deployCreationPolicy.globalLimit);
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5567
|
+
async function enforceClientTrafficQuota(clientKey, bucket) {
|
|
5568
|
+
if (typeof resolvedStore.incrementClientQuota !== "function") {
|
|
5569
|
+
return;
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5572
|
+
const limit =
|
|
5573
|
+
bucket === "mutations"
|
|
5574
|
+
? clientTrafficPolicy.mutationsPerClientLimit
|
|
5575
|
+
: clientTrafficPolicy.requestsPerClientLimit;
|
|
5576
|
+
await resolvedStore.incrementClientQuota(clientKey, clientTrafficQuotaBucket(bucket), limit);
|
|
5577
|
+
}
|
|
5578
|
+
|
|
3997
5579
|
async function refreshDeploySubscriptions(deploy) {
|
|
3998
5580
|
const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
3999
5581
|
if (!storedArtifact) {
|
|
@@ -4033,6 +5615,30 @@ export async function startAnonymousServer({
|
|
|
4033
5615
|
}
|
|
4034
5616
|
}
|
|
4035
5617
|
|
|
5618
|
+
function cleanupTouchedResources(stats) {
|
|
5619
|
+
return (
|
|
5620
|
+
Number(stats?.markedTerminated ?? 0) +
|
|
5621
|
+
Number(stats?.deletedDeploys ?? 0) +
|
|
5622
|
+
Number(stats?.deletedStateRows ?? 0) +
|
|
5623
|
+
Number(stats?.deletedLogEntries ?? 0) +
|
|
5624
|
+
Number(stats?.deletedArtifacts ?? 0)
|
|
5625
|
+
);
|
|
5626
|
+
}
|
|
5627
|
+
|
|
5628
|
+
async function runCleanup(reason = "periodic") {
|
|
5629
|
+
if (typeof resolvedStore.cleanupExpiredDeploys !== "function") {
|
|
5630
|
+
return null;
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
const stats = await resolvedStore.cleanupExpiredDeploys(cleanupPolicy);
|
|
5634
|
+
if (!quiet && cleanupTouchedResources(stats) > 0) {
|
|
5635
|
+
console.log(
|
|
5636
|
+
`[lakebed:cleanup] ${reason} marked=${stats.markedTerminated} deleted=${stats.deletedDeploys} stateRows=${stats.deletedStateRows} logs=${stats.deletedLogEntries} artifacts=${stats.deletedArtifacts}`
|
|
5637
|
+
);
|
|
5638
|
+
}
|
|
5639
|
+
return stats;
|
|
5640
|
+
}
|
|
5641
|
+
|
|
4036
5642
|
async function publishDeploy(deployId) {
|
|
4037
5643
|
for (const [ws, subscription] of subscriptions) {
|
|
4038
5644
|
if (subscription.deploy.id !== deployId) {
|
|
@@ -4379,7 +5985,92 @@ export async function startAnonymousServer({
|
|
|
4379
5985
|
return;
|
|
4380
5986
|
}
|
|
4381
5987
|
|
|
5988
|
+
const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
|
|
5989
|
+
if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
|
|
5990
|
+
const deployId = decodeURIComponent(deployDomainsMatch[1]);
|
|
5991
|
+
const currentDeploy = await resolvedStore.getDeployById(deployId);
|
|
5992
|
+
if (!currentDeploy) {
|
|
5993
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
5994
|
+
return;
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
if (!canManageDeploy(req, currentDeploy)) {
|
|
5998
|
+
sendJson(res, 401, { error: "Invalid deploy token." });
|
|
5999
|
+
return;
|
|
6000
|
+
}
|
|
6001
|
+
|
|
6002
|
+
if (req.method === "GET") {
|
|
6003
|
+
sendJson(res, 200, {
|
|
6004
|
+
deployId: currentDeploy.id,
|
|
6005
|
+
domains:
|
|
6006
|
+
typeof resolvedStore.listDeployDomainsForDeploy === "function"
|
|
6007
|
+
? (await resolvedStore.listDeployDomainsForDeploy(currentDeploy.id)).map(responseForDeployDomain)
|
|
6008
|
+
: []
|
|
6009
|
+
});
|
|
6010
|
+
return;
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
if (!currentDeploy.ownerId) {
|
|
6014
|
+
sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
|
|
6015
|
+
return;
|
|
6016
|
+
}
|
|
6017
|
+
if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
|
|
6018
|
+
sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
|
|
6019
|
+
return;
|
|
6020
|
+
}
|
|
6021
|
+
|
|
6022
|
+
let normalizedDomain;
|
|
6023
|
+
try {
|
|
6024
|
+
const body = await readJsonBody(req, 8192);
|
|
6025
|
+
normalizedDomain = normalizeLakebedSubdomainInput(body.hostname ?? body.subdomain, resolvedAppBaseDomain);
|
|
6026
|
+
} catch (error) {
|
|
6027
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
6028
|
+
return;
|
|
6029
|
+
}
|
|
6030
|
+
|
|
6031
|
+
const created = await resolvedStore.createLakebedSubdomain({
|
|
6032
|
+
deployId: currentDeploy.id,
|
|
6033
|
+
hostname: normalizedDomain.hostname,
|
|
6034
|
+
label: normalizedDomain.label,
|
|
6035
|
+
ownerId: currentDeploy.ownerId
|
|
6036
|
+
});
|
|
6037
|
+
if (created.status === "missing") {
|
|
6038
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6039
|
+
return;
|
|
6040
|
+
}
|
|
6041
|
+
if (created.status === "unclaimed") {
|
|
6042
|
+
sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
|
|
6043
|
+
return;
|
|
6044
|
+
}
|
|
6045
|
+
if (created.status === "forbidden") {
|
|
6046
|
+
sendJson(res, 403, { error: "Deploy is claimed by another developer." });
|
|
6047
|
+
return;
|
|
6048
|
+
}
|
|
6049
|
+
if (created.status === "inactive") {
|
|
6050
|
+
sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
|
|
6051
|
+
return;
|
|
6052
|
+
}
|
|
6053
|
+
if (created.status === "conflict") {
|
|
6054
|
+
sendJson(res, 409, { error: "Subdomain is already registered." });
|
|
6055
|
+
return;
|
|
6056
|
+
}
|
|
6057
|
+
if (created.status === "slug_conflict") {
|
|
6058
|
+
sendJson(res, 409, { error: "Subdomain is already used by a generated Lakebed deploy URL." });
|
|
6059
|
+
return;
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
if (created.status === "created") {
|
|
6063
|
+
await resolvedStore.appendLog(currentDeploy.id, "info", "lakebed subdomain registered", {
|
|
6064
|
+
hostname: created.domain.hostname
|
|
6065
|
+
});
|
|
6066
|
+
}
|
|
6067
|
+
|
|
6068
|
+
sendJson(res, created.status === "created" ? 201 : 200, responseForDeployDomain(created.domain));
|
|
6069
|
+
return;
|
|
6070
|
+
}
|
|
6071
|
+
|
|
4382
6072
|
if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
|
|
6073
|
+
await enforceAnonymousDeployCreation(req);
|
|
4383
6074
|
const body = await readJsonBody(req);
|
|
4384
6075
|
const payload = validateAnonymousDeployPayload(body);
|
|
4385
6076
|
if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
|
|
@@ -4393,7 +6084,6 @@ export async function startAnonymousServer({
|
|
|
4393
6084
|
clientBundleBase64: payload.clientBundleBase64,
|
|
4394
6085
|
clientBundleHash: payload.clientBundleHash,
|
|
4395
6086
|
publicRootUrl: resolvedPublicRootUrl,
|
|
4396
|
-
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
|
|
4397
6087
|
serverEnv: payload.serverEnv
|
|
4398
6088
|
});
|
|
4399
6089
|
await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
|
|
@@ -4428,7 +6118,6 @@ export async function startAnonymousServer({
|
|
|
4428
6118
|
clientBundleHash: payload.clientBundleHash,
|
|
4429
6119
|
deployId,
|
|
4430
6120
|
publicRootUrl: resolvedPublicRootUrl,
|
|
4431
|
-
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
|
|
4432
6121
|
serverEnv: payload.serverEnv
|
|
4433
6122
|
});
|
|
4434
6123
|
if (!deploy) {
|
|
@@ -4466,6 +6155,8 @@ export async function startAnonymousServer({
|
|
|
4466
6155
|
return;
|
|
4467
6156
|
}
|
|
4468
6157
|
|
|
6158
|
+
const clientKey = forwardedClientKey(req);
|
|
6159
|
+
await enforceClientTrafficQuota(clientKey, "requests");
|
|
4469
6160
|
await resolvedStore.incrementQuota(
|
|
4470
6161
|
loaded.deploy.id,
|
|
4471
6162
|
"requests",
|
|
@@ -4495,16 +6186,28 @@ export async function startAnonymousServer({
|
|
|
4495
6186
|
|
|
4496
6187
|
sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
4497
6188
|
} catch (error) {
|
|
6189
|
+
if (isQuotaError(error)) {
|
|
6190
|
+
sendJson(
|
|
6191
|
+
res,
|
|
6192
|
+
error.status ?? 429,
|
|
6193
|
+
quotaErrorBody(error, {
|
|
6194
|
+
signInUrl: `${resolvedPublicRootUrl}/auth/github?return_to=${encodeURIComponent("/deploys")}`
|
|
6195
|
+
}),
|
|
6196
|
+
quotaErrorHeaders(error)
|
|
6197
|
+
);
|
|
6198
|
+
return;
|
|
6199
|
+
}
|
|
4498
6200
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
4499
6201
|
}
|
|
4500
6202
|
});
|
|
4501
6203
|
|
|
4502
6204
|
const wss = new WebSocketServer({ noServer: true });
|
|
4503
6205
|
|
|
4504
|
-
wss.on("connection", (ws,
|
|
6206
|
+
wss.on("connection", (ws, req, loaded, auth) => {
|
|
4505
6207
|
subscriptions.set(ws, {
|
|
4506
6208
|
artifact: loaded.artifact,
|
|
4507
6209
|
auth,
|
|
6210
|
+
clientKey: forwardedClientKey(req),
|
|
4508
6211
|
deploy: loaded.deploy,
|
|
4509
6212
|
queries: new Set()
|
|
4510
6213
|
});
|
|
@@ -4525,6 +6228,7 @@ export async function startAnonymousServer({
|
|
|
4525
6228
|
}
|
|
4526
6229
|
|
|
4527
6230
|
try {
|
|
6231
|
+
await enforceClientTrafficQuota(subscription.clientKey, "requests");
|
|
4528
6232
|
await resolvedStore.incrementQuota(
|
|
4529
6233
|
subscription.deploy.id,
|
|
4530
6234
|
"requests",
|
|
@@ -4551,6 +6255,7 @@ export async function startAnonymousServer({
|
|
|
4551
6255
|
}
|
|
4552
6256
|
|
|
4553
6257
|
if (message.op === "mutation.run") {
|
|
6258
|
+
await enforceClientTrafficQuota(subscription.clientKey, "mutations");
|
|
4554
6259
|
await resolvedStore.incrementQuota(
|
|
4555
6260
|
subscription.deploy.id,
|
|
4556
6261
|
"mutations",
|
|
@@ -4577,7 +6282,14 @@ export async function startAnonymousServer({
|
|
|
4577
6282
|
error: error instanceof Error ? error.message : String(error),
|
|
4578
6283
|
op: message?.op
|
|
4579
6284
|
});
|
|
6285
|
+
const quota = isQuotaError(error) ? quotaErrorBody(error) : null;
|
|
4580
6286
|
websocketSend(ws, {
|
|
6287
|
+
...(quota
|
|
6288
|
+
? {
|
|
6289
|
+
code: quota.code,
|
|
6290
|
+
quota
|
|
6291
|
+
}
|
|
6292
|
+
: {}),
|
|
4581
6293
|
error: error instanceof Error ? error.message : String(error),
|
|
4582
6294
|
id: message?.id,
|
|
4583
6295
|
ok: false,
|
|
@@ -4624,6 +6336,20 @@ export async function startAnonymousServer({
|
|
|
4624
6336
|
});
|
|
4625
6337
|
});
|
|
4626
6338
|
|
|
6339
|
+
void runCleanup("startup").catch((error) => {
|
|
6340
|
+
if (!quiet) {
|
|
6341
|
+
console.error(`[lakebed:cleanup] startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6342
|
+
}
|
|
6343
|
+
});
|
|
6344
|
+
cleanupInterval = setInterval(() => {
|
|
6345
|
+
void runCleanup("periodic").catch((error) => {
|
|
6346
|
+
if (!quiet) {
|
|
6347
|
+
console.error(`[lakebed:cleanup] periodic failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6348
|
+
}
|
|
6349
|
+
});
|
|
6350
|
+
}, cleanupPolicy.intervalSeconds * 1000);
|
|
6351
|
+
cleanupInterval.unref?.();
|
|
6352
|
+
|
|
4627
6353
|
if (!quiet) {
|
|
4628
6354
|
console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
|
|
4629
6355
|
}
|
|
@@ -4635,6 +6361,9 @@ export async function startAnonymousServer({
|
|
|
4635
6361
|
store: resolvedStore,
|
|
4636
6362
|
url: resolvedPublicRootUrl,
|
|
4637
6363
|
async close() {
|
|
6364
|
+
if (cleanupInterval) {
|
|
6365
|
+
clearInterval(cleanupInterval);
|
|
6366
|
+
}
|
|
4638
6367
|
for (const client of wss.clients) {
|
|
4639
6368
|
client.close();
|
|
4640
6369
|
}
|