lakebed 0.0.18 → 0.0.20
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 +46 -30
- package/package.json +5 -5
- package/src/anonymous-server.js +393 -8
- package/src/anonymous.js +241 -0
- package/src/cli.js +401 -79
- package/src/client.d.ts +38 -0
- package/src/client.js +285 -3
- package/src/server.d.ts +49 -0
- package/src/server.js +53 -0
- package/src/source-runtime-worker.js +114 -1
- package/src/source-runtime.js +27 -0
- package/src/version.js +1 -1
package/src/cli.js
CHANGED
|
@@ -31,26 +31,27 @@ const packageNodeModules = resolve(packageDir, "node_modules");
|
|
|
31
31
|
const sourceNamespace = "lakebed-source";
|
|
32
32
|
const defaultDeployApiUrl = "https://api.lakebed.app";
|
|
33
33
|
const execFileAsync = promisify(execFile);
|
|
34
|
+
const endpointBodyMaxBytes = 2 * 1024 * 1024;
|
|
34
35
|
|
|
35
36
|
function usage() {
|
|
36
37
|
console.log(`lakebed
|
|
37
38
|
|
|
38
39
|
Usage:
|
|
39
|
-
lakebed new [name] [--template todo] [--no-git]
|
|
40
|
-
lakebed create [name] [--template todo] [--no-git]
|
|
41
|
-
lakebed dev [capsule-dir] [--port 3000]
|
|
42
|
-
lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
43
|
-
lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
|
|
44
|
-
lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
45
|
-
lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
|
|
46
|
-
lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
47
|
-
lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
|
|
48
|
-
lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
|
|
49
|
-
lakebed auth as <name>
|
|
50
|
-
lakebed auth reset
|
|
51
|
-
lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
52
|
-
lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
53
|
-
lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
40
|
+
npx lakebed new [name] [--template todo] [--no-git]
|
|
41
|
+
npx lakebed create [name] [--template todo] [--no-git]
|
|
42
|
+
npx lakebed dev [capsule-dir] [--port 3000]
|
|
43
|
+
npx lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
44
|
+
npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
|
|
45
|
+
npx lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
46
|
+
npx lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
|
|
47
|
+
npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
48
|
+
npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
|
|
49
|
+
npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
|
|
50
|
+
npx lakebed auth as <name>
|
|
51
|
+
npx lakebed auth reset
|
|
52
|
+
npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
53
|
+
npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
54
|
+
npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
54
55
|
`);
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -451,10 +452,201 @@ function html(title, { shooBaseUrl } = {}) {
|
|
|
451
452
|
</html>`;
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
function wantsHtml(req) {
|
|
456
|
+
const accept = String(req.headers.accept ?? "");
|
|
457
|
+
return !accept || accept.includes("text/html");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isReservedClientShellPath(pathname) {
|
|
461
|
+
return (
|
|
462
|
+
pathname === "/client.js" ||
|
|
463
|
+
pathname === "/__lakebed" ||
|
|
464
|
+
pathname.startsWith("/__lakebed/") ||
|
|
465
|
+
pathname === "/__span" ||
|
|
466
|
+
pathname.startsWith("/__span/") ||
|
|
467
|
+
(pathname.startsWith("/auth/") && pathname !== "/auth/callback")
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function isClientShellRequest(req, pathname) {
|
|
472
|
+
return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
|
|
473
|
+
}
|
|
474
|
+
|
|
454
475
|
function sendJson(ws, message) {
|
|
455
476
|
ws.send(JSON.stringify(message));
|
|
456
477
|
}
|
|
457
478
|
|
|
479
|
+
async function readRequestBody(req, maxBytes = endpointBodyMaxBytes) {
|
|
480
|
+
const chunks = [];
|
|
481
|
+
let total = 0;
|
|
482
|
+
for await (const chunk of req) {
|
|
483
|
+
total += chunk.byteLength;
|
|
484
|
+
if (total > maxBytes) {
|
|
485
|
+
throw new Error(`Request body exceeds ${maxBytes} bytes.`);
|
|
486
|
+
}
|
|
487
|
+
chunks.push(chunk);
|
|
488
|
+
}
|
|
489
|
+
return Buffer.concat(chunks);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function headersFromNodeRequest(headers) {
|
|
493
|
+
const clean = {};
|
|
494
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
495
|
+
if (Array.isArray(value)) {
|
|
496
|
+
clean[name.toLowerCase()] = value.join(", ");
|
|
497
|
+
} else if (value !== undefined) {
|
|
498
|
+
clean[name.toLowerCase()] = String(value);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return clean;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function createEndpointRequest({ body, headers, method, url }) {
|
|
505
|
+
const requestUrl = new URL(url.href);
|
|
506
|
+
const headerMap = new Map(Object.entries(headersFromNodeRequest(headers)));
|
|
507
|
+
const requestBody = Buffer.from(body);
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
headers: {
|
|
511
|
+
entries() {
|
|
512
|
+
return headerMap.entries();
|
|
513
|
+
},
|
|
514
|
+
get(name) {
|
|
515
|
+
return headerMap.get(String(name).toLowerCase()) ?? null;
|
|
516
|
+
},
|
|
517
|
+
has(name) {
|
|
518
|
+
return headerMap.has(String(name).toLowerCase());
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
method: String(method ?? "GET").toUpperCase(),
|
|
522
|
+
path: requestUrl.pathname,
|
|
523
|
+
query: new URLSearchParams(requestUrl.searchParams),
|
|
524
|
+
url: requestUrl.href,
|
|
525
|
+
async bytes() {
|
|
526
|
+
return new Uint8Array(requestBody);
|
|
527
|
+
},
|
|
528
|
+
async json() {
|
|
529
|
+
return JSON.parse(requestBody.toString("utf8"));
|
|
530
|
+
},
|
|
531
|
+
async text() {
|
|
532
|
+
return requestBody.toString("utf8");
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function endpointBodyToBuffer(body) {
|
|
538
|
+
if (body === undefined || body === null) {
|
|
539
|
+
return Buffer.alloc(0);
|
|
540
|
+
}
|
|
541
|
+
if (Buffer.isBuffer(body)) {
|
|
542
|
+
return body;
|
|
543
|
+
}
|
|
544
|
+
if (typeof body === "string") {
|
|
545
|
+
return Buffer.from(body, "utf8");
|
|
546
|
+
}
|
|
547
|
+
if (body instanceof ArrayBuffer) {
|
|
548
|
+
return Buffer.from(body);
|
|
549
|
+
}
|
|
550
|
+
if (ArrayBuffer.isView(body)) {
|
|
551
|
+
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
552
|
+
}
|
|
553
|
+
return Buffer.from(JSON.stringify(body ?? null), "utf8");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function endpointStatus(status, fallback = 200) {
|
|
557
|
+
const parsed = Number(status);
|
|
558
|
+
return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : fallback;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function headersToObject(headers = {}) {
|
|
562
|
+
if (!headers) {
|
|
563
|
+
return {};
|
|
564
|
+
}
|
|
565
|
+
if (typeof headers.entries === "function") {
|
|
566
|
+
return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
|
|
567
|
+
}
|
|
568
|
+
if (Array.isArray(headers)) {
|
|
569
|
+
return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
|
|
570
|
+
}
|
|
571
|
+
if (typeof headers === "object") {
|
|
572
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [String(key), String(value)]));
|
|
573
|
+
}
|
|
574
|
+
return {};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function normalizeEndpointResponse(result) {
|
|
578
|
+
if (result === undefined || result === null) {
|
|
579
|
+
return { body: Buffer.alloc(0), headers: {}, status: 204 };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (typeof Response !== "undefined" && result instanceof Response) {
|
|
583
|
+
return {
|
|
584
|
+
body: Buffer.from(await result.arrayBuffer()),
|
|
585
|
+
headers: headersToObject(result.headers),
|
|
586
|
+
status: endpointStatus(result.status)
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (typeof result === "object" && result?.kind === "response") {
|
|
591
|
+
return {
|
|
592
|
+
body: endpointBodyToBuffer(result.body),
|
|
593
|
+
headers: headersToObject(result.headers),
|
|
594
|
+
status: endpointStatus(result.status)
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (typeof result === "string") {
|
|
599
|
+
return {
|
|
600
|
+
body: endpointBodyToBuffer(result),
|
|
601
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
602
|
+
status: 200
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
body: endpointBodyToBuffer(result),
|
|
608
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
609
|
+
status: 200
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function sanitizeEndpointResponseHeaders(headers = {}) {
|
|
614
|
+
const blocked = new Set(["connection", "content-length", "date", "keep-alive", "transfer-encoding", "upgrade"]);
|
|
615
|
+
const clean = {};
|
|
616
|
+
for (const [rawName, rawValue] of Object.entries(headers)) {
|
|
617
|
+
const name = String(rawName);
|
|
618
|
+
const lower = name.toLowerCase();
|
|
619
|
+
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/i.test(name) || blocked.has(lower)) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
clean[name] = String(rawValue);
|
|
623
|
+
}
|
|
624
|
+
return clean;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function sendEndpointResponse(res, response) {
|
|
628
|
+
const body = response.body ?? Buffer.alloc(0);
|
|
629
|
+
res.writeHead(response.status, {
|
|
630
|
+
...sanitizeEndpointResponseHeaders(response.headers),
|
|
631
|
+
"Content-Length": String(body.byteLength)
|
|
632
|
+
});
|
|
633
|
+
res.end(body);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function endpointDefinitions(app) {
|
|
637
|
+
return Object.entries(app.endpoints ?? {}).map(([name, endpoint]) => ({
|
|
638
|
+
handler: endpoint?.handler ?? endpoint,
|
|
639
|
+
method: String(endpoint?.method ?? "").toUpperCase(),
|
|
640
|
+
name,
|
|
641
|
+
path: String(endpoint?.path ?? "")
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function findEndpoint(app, method, path) {
|
|
646
|
+
const requestMethod = String(method ?? "GET").toUpperCase();
|
|
647
|
+
return endpointDefinitions(app).find((endpoint) => endpoint.method === requestMethod && endpoint.path === path) ?? null;
|
|
648
|
+
}
|
|
649
|
+
|
|
458
650
|
async function capsuleFileFingerprint(rootDir, dir = rootDir, entries = []) {
|
|
459
651
|
const dirEntries = await readdir(dir, { withFileTypes: true });
|
|
460
652
|
|
|
@@ -518,6 +710,25 @@ async function runMutation({ app, stateCell, auth, logs, env, name, args }) {
|
|
|
518
710
|
);
|
|
519
711
|
}
|
|
520
712
|
|
|
713
|
+
async function runEndpoint({ app, stateCell, auth, logs, env, endpoint, request }) {
|
|
714
|
+
if (typeof endpoint.handler !== "function") {
|
|
715
|
+
throw new Error(`Unknown endpoint: ${endpoint.name}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return stateCell.transaction(async (db) => {
|
|
719
|
+
const result = await endpoint.handler(
|
|
720
|
+
{
|
|
721
|
+
auth,
|
|
722
|
+
db,
|
|
723
|
+
env,
|
|
724
|
+
log: logs.createLogger()
|
|
725
|
+
},
|
|
726
|
+
request
|
|
727
|
+
);
|
|
728
|
+
return normalizeEndpointResponse(result);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
521
732
|
export async function startDevServer({
|
|
522
733
|
capsuleDir,
|
|
523
734
|
sourceStore,
|
|
@@ -596,6 +807,44 @@ export async function startDevServer({
|
|
|
596
807
|
return;
|
|
597
808
|
}
|
|
598
809
|
|
|
810
|
+
const endpoint = findEndpoint(currentBuild.app, req.method, requestUrl.pathname);
|
|
811
|
+
if (endpoint) {
|
|
812
|
+
const auth = await resolveAuthFromUrl({
|
|
813
|
+
defaultAuth,
|
|
814
|
+
onError: (error) => {
|
|
815
|
+
logs.append("warn", "endpoint auth verification failed", { error: error instanceof Error ? error.message : String(error) });
|
|
816
|
+
},
|
|
817
|
+
origin: requestOrigin(req),
|
|
818
|
+
shooBaseUrl,
|
|
819
|
+
url: requestUrl
|
|
820
|
+
});
|
|
821
|
+
const body = await readRequestBody(req);
|
|
822
|
+
const endpointRequest = createEndpointRequest({
|
|
823
|
+
body,
|
|
824
|
+
headers: req.headers,
|
|
825
|
+
method: req.method,
|
|
826
|
+
url: requestUrl
|
|
827
|
+
});
|
|
828
|
+
const { result: response } = await runEndpoint({
|
|
829
|
+
app: currentBuild.app,
|
|
830
|
+
auth,
|
|
831
|
+
endpoint,
|
|
832
|
+
env: currentBuild.env,
|
|
833
|
+
logs,
|
|
834
|
+
request: endpointRequest,
|
|
835
|
+
stateCell
|
|
836
|
+
});
|
|
837
|
+
sendEndpointResponse(res, response);
|
|
838
|
+
await publishAll();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (isClientShellRequest(req, requestUrl.pathname)) {
|
|
843
|
+
res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8" });
|
|
844
|
+
res.end(html(currentBuild.app.name ?? "Lakebed Capsule", { shooBaseUrl }));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
599
848
|
res.writeHead(404);
|
|
600
849
|
res.end("Not found");
|
|
601
850
|
} catch (error) {
|
|
@@ -844,7 +1093,7 @@ export default capsule({
|
|
|
844
1093
|
<p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
|
|
845
1094
|
<h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
|
|
846
1095
|
<p className="mt-4 text-neutral-300">
|
|
847
|
-
This capsule uses ${feature}. Claim this deploy, then run lakebed deploy again to publish the app.
|
|
1096
|
+
This capsule uses ${feature}. Claim this deploy, then run npx lakebed deploy again to publish the app.
|
|
848
1097
|
</p>
|
|
849
1098
|
</section>
|
|
850
1099
|
</main>
|
|
@@ -950,7 +1199,7 @@ function claimUrlFromDeployMetadata(metadata) {
|
|
|
950
1199
|
}
|
|
951
1200
|
|
|
952
1201
|
function claimCommandText({ api, capsuleArg }) {
|
|
953
|
-
const parts = ["lakebed", "claim"];
|
|
1202
|
+
const parts = ["npx", "lakebed", "claim"];
|
|
954
1203
|
if (capsuleArg) {
|
|
955
1204
|
parts.push(capsuleArg);
|
|
956
1205
|
}
|
|
@@ -1088,7 +1337,7 @@ async function readResponseJson(response) {
|
|
|
1088
1337
|
|
|
1089
1338
|
async function deployCommand(args) {
|
|
1090
1339
|
if (args.some((arg) => arg === "--ttl" || arg.startsWith("--ttl="))) {
|
|
1091
|
-
throw new Error("lakebed deploy no longer accepts --ttl. Deploy expiry is set by the server.");
|
|
1340
|
+
throw new Error("npx lakebed deploy no longer accepts --ttl. Deploy expiry is set by the server.");
|
|
1092
1341
|
}
|
|
1093
1342
|
|
|
1094
1343
|
const [capsuleArg] = positionals(args);
|
|
@@ -1115,7 +1364,7 @@ async function deployCommand(args) {
|
|
|
1115
1364
|
if (!currentDeploy?.claimed && hasServerEnvValues) {
|
|
1116
1365
|
if (canUpdate && currentDeploy) {
|
|
1117
1366
|
throw new Error(
|
|
1118
|
-
`This capsule defines server env in ${SERVER_ENV_FILE}.\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to sync server env.`
|
|
1367
|
+
`This capsule defines server env in ${SERVER_ENV_FILE}.\n\nThis deploy is still anonymous. Claim it first, then run npx lakebed deploy again to sync server env.`
|
|
1119
1368
|
);
|
|
1120
1369
|
}
|
|
1121
1370
|
try {
|
|
@@ -1132,7 +1381,7 @@ async function deployCommand(args) {
|
|
|
1132
1381
|
} catch (error) {
|
|
1133
1382
|
if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
|
|
1134
1383
|
throw new Error(
|
|
1135
|
-
`${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
|
|
1384
|
+
`${error.message}\n\nThis deploy is still anonymous. Claim it first, then run npx lakebed deploy again to use server-side fetch.`
|
|
1136
1385
|
);
|
|
1137
1386
|
}
|
|
1138
1387
|
if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
|
|
@@ -1214,7 +1463,7 @@ async function deployCommand(args) {
|
|
|
1214
1463
|
if (deployed.claimUrl) {
|
|
1215
1464
|
console.log(`Claim: ${claimCommandText({ api, capsuleArg })}`);
|
|
1216
1465
|
}
|
|
1217
|
-
console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
|
|
1466
|
+
console.log(`Inspect: npx lakebed inspect ${deployed.deployId}`);
|
|
1218
1467
|
if (deployed.inspectPolicy === "public") {
|
|
1219
1468
|
console.log("Inspect policy: public - data and logs are readable by anyone with the app URL.");
|
|
1220
1469
|
}
|
|
@@ -1232,7 +1481,7 @@ async function deployCommand(args) {
|
|
|
1232
1481
|
}
|
|
1233
1482
|
if (envelope.claimRequired) {
|
|
1234
1483
|
console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
|
|
1235
|
-
console.log(`Run ${claimCommandText({ api, capsuleArg })}, then run lakebed deploy again.`);
|
|
1484
|
+
console.log(`Run ${claimCommandText({ api, capsuleArg })}, then run npx lakebed deploy again.`);
|
|
1236
1485
|
}
|
|
1237
1486
|
}
|
|
1238
1487
|
|
|
@@ -1243,7 +1492,7 @@ async function claimCommand(args) {
|
|
|
1243
1492
|
const metadata = await readDeployMetadata(capsuleDir);
|
|
1244
1493
|
|
|
1245
1494
|
if (!metadata) {
|
|
1246
|
-
throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
|
|
1495
|
+
throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
|
|
1247
1496
|
}
|
|
1248
1497
|
|
|
1249
1498
|
if (metadata.api !== api) {
|
|
@@ -1307,7 +1556,7 @@ async function domainsCommand(args) {
|
|
|
1307
1556
|
const api = deployApiUrl(args);
|
|
1308
1557
|
const metadata = await readDeployMetadata(capsuleDir);
|
|
1309
1558
|
if (!metadata) {
|
|
1310
|
-
throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
|
|
1559
|
+
throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
|
|
1311
1560
|
}
|
|
1312
1561
|
if (metadata.api !== api) {
|
|
1313
1562
|
throw new Error(`Saved deploy metadata is for ${metadata.api}, but this command is using ${api}. Pass --api ${metadata.api} to use it.`);
|
|
@@ -1432,6 +1681,10 @@ async function inspectCommand(args) {
|
|
|
1432
1681
|
if (Array.isArray(manifest.mutations)) {
|
|
1433
1682
|
console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
|
|
1434
1683
|
}
|
|
1684
|
+
if (Array.isArray(manifest.endpoints)) {
|
|
1685
|
+
const endpoints = manifest.endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path} -> ${endpoint.name}`);
|
|
1686
|
+
console.log(`Endpoints: ${endpoints.join(", ") || "(none)"}`);
|
|
1687
|
+
}
|
|
1435
1688
|
if (Array.isArray(manifest.mutationDetails) && manifest.mutationDetails.length > 0) {
|
|
1436
1689
|
console.log("Mutation runtime:");
|
|
1437
1690
|
for (const detail of manifest.mutationDetails) {
|
|
@@ -1508,13 +1761,34 @@ async function dbCommand(args) {
|
|
|
1508
1761
|
}
|
|
1509
1762
|
|
|
1510
1763
|
function agentInstructionsTemplate() {
|
|
1511
|
-
return `#
|
|
1764
|
+
return `# Building with Lakebed.
|
|
1765
|
+
|
|
1766
|
+
This directory is for a Lakebed "capsule". Lakebed is an all-inclusive suite of tools to build web applications purely from code and a CLI.
|
|
1767
|
+
|
|
1768
|
+
Your role is to build software within this capsule. Lakebed is the runtime, the compiler, the database, and the hosting platform. You will be able to control all of this just by writing code and running commands through the CLI.
|
|
1769
|
+
|
|
1770
|
+
## Hard rules
|
|
1512
1771
|
|
|
1513
|
-
|
|
1772
|
+
- No installing node modules. You can use the built-in APIs. Write TypeScript for anything that is not included.
|
|
1773
|
+
- Lakebed CLI should always be run with \`npx lakebed [command]\`. It is not a global. Launch with \`npx\` always.
|
|
1774
|
+
- All client code goes in the \`client\` directory, and all server code goes in the \`server\` directory. Shared code can go in \`shared\`.
|
|
1775
|
+
- Use \`lakebed/server\` only from \`server/*.ts\`.
|
|
1776
|
+
- Use \`lakebed/client\` only from \`client/*.tsx\`.
|
|
1777
|
+
- Data needed on client should be fetched through queries. User-driven changes should be done via mutations. Endpoints should be treated as an "escape hatch" for exposing functionality over endpoints for HTTP-based flows.
|
|
1778
|
+
- Styling must be done via raw CSS or Tailwind classes in the JSX.
|
|
1779
|
+
- Do not add a CSS, PostCSS, or Tailwind build pipeline. They are built in.
|
|
1780
|
+
- There is no file based routing. Use the built-in client router from \`lakebed/client\` when you need pages.
|
|
1781
|
+
- All imports must be from Lakebed or from relative paths.
|
|
1782
|
+
- Do not use Node built-ins in app code.
|
|
1783
|
+
- Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
|
|
1784
|
+
- Read server-only environment variables through \`ctx.env\`; define them in \`.env.lakebed.server\`.
|
|
1785
|
+
- Auth can be added with a Google sign-in using \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
|
|
1786
|
+
- Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
|
|
1787
|
+
- Environment variables are only available on the server, and must be defined in \`.env.lakebed.server\`. They are not available during build time. If you need build-time environment variables, define them in code and do conditional logic based on them. They will be synced with production on \`npx lakebed deploy\`.
|
|
1514
1788
|
|
|
1515
|
-
##
|
|
1789
|
+
## Default project structure
|
|
1516
1790
|
|
|
1517
|
-
- \`server/index.ts\`: schema, queries, and
|
|
1791
|
+
- \`server/index.ts\`: schema, queries, mutations, and external endpoints.
|
|
1518
1792
|
- \`client/index.tsx\`: Preact UI entrypoint.
|
|
1519
1793
|
- \`shared/\`: pure TypeScript shared by client and server.
|
|
1520
1794
|
|
|
@@ -1523,35 +1797,27 @@ This is a Lakebed capsule. Build the app inside this directory using the Lakebed
|
|
|
1523
1797
|
Run locally:
|
|
1524
1798
|
|
|
1525
1799
|
\`\`\`sh
|
|
1526
|
-
lakebed dev
|
|
1800
|
+
npx lakebed dev
|
|
1527
1801
|
\`\`\`
|
|
1528
1802
|
|
|
1529
1803
|
Deploy:
|
|
1530
1804
|
|
|
1531
1805
|
\`\`\`sh
|
|
1532
|
-
lakebed deploy
|
|
1806
|
+
npx lakebed deploy
|
|
1533
1807
|
\`\`\`
|
|
1534
1808
|
|
|
1535
|
-
Inspect local state while \`lakebed dev\` is running:
|
|
1809
|
+
Inspect local state while \`npx lakebed dev\` is running:
|
|
1536
1810
|
|
|
1537
1811
|
\`\`\`sh
|
|
1538
|
-
lakebed db list --port 3000
|
|
1539
|
-
lakebed db dump --port 3000
|
|
1540
|
-
lakebed logs --port 3000
|
|
1812
|
+
npx lakebed db list --port 3000
|
|
1813
|
+
npx lakebed db dump --port 3000
|
|
1814
|
+
npx lakebed logs --port 3000
|
|
1541
1815
|
\`\`\`
|
|
1542
1816
|
|
|
1543
|
-
##
|
|
1817
|
+
## Additional resources
|
|
1544
1818
|
|
|
1545
|
-
-
|
|
1546
|
-
-
|
|
1547
|
-
- Do not import npm packages from app code.
|
|
1548
|
-
- Do not use Node built-ins in app code.
|
|
1549
|
-
- Use Tailwind classes directly in JSX.
|
|
1550
|
-
- Do not add a CSS, PostCSS, or Tailwind build pipeline.
|
|
1551
|
-
- Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
|
|
1552
|
-
- Read server-only environment variables through \`ctx.env\`; define them in \`.env.lakebed.server\`.
|
|
1553
|
-
- Add Google sign-in with \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
|
|
1554
|
-
- Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
|
|
1819
|
+
- [Lakebed docs](https://docs.lakebed.dev/)
|
|
1820
|
+
- [Capsule API docs](https://docs.lakebed.dev/capsule-api/)
|
|
1555
1821
|
|
|
1556
1822
|
## Current Limits
|
|
1557
1823
|
|
|
@@ -1561,7 +1827,8 @@ lakebed logs --port 3000
|
|
|
1561
1827
|
- No file storage.
|
|
1562
1828
|
- No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
|
|
1563
1829
|
- Non-empty \`.env.lakebed.server\` files sync only after a deploy is claimed.
|
|
1564
|
-
- Local state resets when \`lakebed dev\` restarts.
|
|
1830
|
+
- Local state resets when \`npx lakebed dev\` restarts.
|
|
1831
|
+
- All production deploys are on 'lakebed.app'
|
|
1565
1832
|
`;
|
|
1566
1833
|
}
|
|
1567
1834
|
|
|
@@ -1571,7 +1838,7 @@ function todoTemplate(name) {
|
|
|
1571
1838
|
return {
|
|
1572
1839
|
"AGENTS.md": agentInstructions,
|
|
1573
1840
|
"CLAUDE.md": agentInstructions,
|
|
1574
|
-
"server/index.ts": `import { boolean, capsule, mutation, query, string, table } from "lakebed/server";
|
|
1841
|
+
"server/index.ts": `import { boolean, capsule, endpoint, mutation, query, string, table, text } from "lakebed/server";
|
|
1575
1842
|
import { cleanTodoText } from "../shared/todo";
|
|
1576
1843
|
|
|
1577
1844
|
export default capsule({
|
|
@@ -1603,10 +1870,15 @@ export default capsule({
|
|
|
1603
1870
|
|
|
1604
1871
|
ctx.db.todos.insert({ text: cleanText, ownerId: ctx.auth.userId });
|
|
1605
1872
|
})
|
|
1873
|
+
},
|
|
1874
|
+
|
|
1875
|
+
endpoints: {
|
|
1876
|
+
status: endpoint({ method: "GET", path: "/api/status" }, () => text("ok"))
|
|
1606
1877
|
}
|
|
1607
1878
|
});
|
|
1608
1879
|
`,
|
|
1609
|
-
"client/index.tsx": `import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
|
|
1880
|
+
"client/index.tsx": `import { Link, Route, Router, Routes, SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
|
|
1881
|
+
import { useState } from "preact/hooks";
|
|
1610
1882
|
import { cleanTodoText, type Todo } from "../shared/todo";
|
|
1611
1883
|
|
|
1612
1884
|
function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
|
|
@@ -1633,12 +1905,9 @@ function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
|
|
|
1633
1905
|
);
|
|
1634
1906
|
}
|
|
1635
1907
|
|
|
1636
|
-
|
|
1637
|
-
const auth = useAuth();
|
|
1908
|
+
function TodoPage() {
|
|
1638
1909
|
const todos = useQuery<Todo[]>("todos");
|
|
1639
1910
|
const addTodo = useMutation<[text: string], void>("addTodo");
|
|
1640
|
-
const authLabel = auth.displayName;
|
|
1641
|
-
const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
|
|
1642
1911
|
|
|
1643
1912
|
async function onSubmit(event: SubmitEvent) {
|
|
1644
1913
|
event.preventDefault();
|
|
@@ -1654,33 +1923,75 @@ export function App() {
|
|
|
1654
1923
|
}
|
|
1655
1924
|
|
|
1656
1925
|
return (
|
|
1657
|
-
<
|
|
1658
|
-
<
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1926
|
+
<section>
|
|
1927
|
+
<h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
|
|
1928
|
+
<form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
|
|
1929
|
+
<input className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2 text-white outline-none focus:border-white" name="text" placeholder="Add a todo" />
|
|
1930
|
+
<button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
|
|
1931
|
+
</form>
|
|
1932
|
+
<ul className="divide-y divide-neutral-800 border-y border-neutral-800">
|
|
1933
|
+
{todos.map((todo) => (
|
|
1934
|
+
<li className="py-3" key={todo.id}>{todo.text}</li>
|
|
1935
|
+
))}
|
|
1936
|
+
</ul>
|
|
1937
|
+
</section>
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function StatusPage() {
|
|
1942
|
+
const [status, setStatus] = useState("not checked");
|
|
1943
|
+
|
|
1944
|
+
async function checkStatus() {
|
|
1945
|
+
const response = await fetch("api/status");
|
|
1946
|
+
setStatus(response.ok ? await response.text() : "error " + response.status);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
return (
|
|
1950
|
+
<section>
|
|
1951
|
+
<h1 className="mb-4 text-4xl font-bold tracking-tight">Status</h1>
|
|
1952
|
+
<p className="mb-6 text-neutral-400">This route calls the server endpoint at /api/status.</p>
|
|
1953
|
+
<button className="border border-white px-4 py-2 font-medium" type="button" onClick={() => void checkStatus()}>
|
|
1954
|
+
Check endpoint
|
|
1955
|
+
</button>
|
|
1956
|
+
<p className="mt-4 font-mono text-sm text-neutral-400">endpoint: {status}</p>
|
|
1957
|
+
</section>
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
export function App() {
|
|
1962
|
+
const auth = useAuth();
|
|
1963
|
+
const authLabel = auth.displayName;
|
|
1964
|
+
const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
|
|
1965
|
+
|
|
1966
|
+
return (
|
|
1967
|
+
<Router>
|
|
1968
|
+
<main className="min-h-screen bg-black px-6 py-10 text-white">
|
|
1969
|
+
<section className="mx-auto max-w-2xl">
|
|
1970
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
1971
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1972
|
+
{!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
|
|
1973
|
+
<p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
|
|
1974
|
+
</div>
|
|
1975
|
+
{!auth.isLoading && auth.isGuest ? (
|
|
1976
|
+
<SignInWithGoogle className="shrink-0 border border-neutral-700 px-3 py-1.5 text-sm font-medium text-neutral-200 hover:border-white hover:text-white" />
|
|
1977
|
+
) : !auth.isLoading ? (
|
|
1978
|
+
<button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
|
|
1979
|
+
Sign out
|
|
1980
|
+
</button>
|
|
1981
|
+
) : null}
|
|
1663
1982
|
</div>
|
|
1664
|
-
|
|
1665
|
-
<
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
</form>
|
|
1677
|
-
<ul className="divide-y divide-neutral-800 border-y border-neutral-800">
|
|
1678
|
-
{todos.map((todo) => (
|
|
1679
|
-
<li className="py-3" key={todo.id}>{todo.text}</li>
|
|
1680
|
-
))}
|
|
1681
|
-
</ul>
|
|
1682
|
-
</section>
|
|
1683
|
-
</main>
|
|
1983
|
+
<nav className="mb-8 flex gap-4 text-sm text-neutral-400">
|
|
1984
|
+
<Link className="hover:text-white" to="/">Todos</Link>
|
|
1985
|
+
<Link className="hover:text-white" to="/status">Status</Link>
|
|
1986
|
+
</nav>
|
|
1987
|
+
<Routes>
|
|
1988
|
+
<Route path="/" element={<TodoPage />} />
|
|
1989
|
+
<Route path="/status" element={<StatusPage />} />
|
|
1990
|
+
<Route path="*" element={<section><h1 className="mb-4 text-4xl font-bold">Not found</h1><Link className="text-neutral-300 hover:text-white" to="/">Back to todos</Link></section>} />
|
|
1991
|
+
</Routes>
|
|
1992
|
+
</section>
|
|
1993
|
+
</main>
|
|
1994
|
+
</Router>
|
|
1684
1995
|
);
|
|
1685
1996
|
}
|
|
1686
1997
|
`,
|
|
@@ -1705,7 +2016,18 @@ export function cleanTodoText(value: string): string {
|
|
|
1705
2016
|
Run this Lakebed capsule:
|
|
1706
2017
|
|
|
1707
2018
|
\`\`\`sh
|
|
1708
|
-
|
|
2019
|
+
npx lakebed dev
|
|
2020
|
+
\`\`\`
|
|
2021
|
+
|
|
2022
|
+
The starter app includes two client routes:
|
|
2023
|
+
|
|
2024
|
+
- \`/\`: the todo list.
|
|
2025
|
+
- \`/status\`: a page that calls the \`GET /api/status\` endpoint.
|
|
2026
|
+
|
|
2027
|
+
You can also call the endpoint directly:
|
|
2028
|
+
|
|
2029
|
+
\`\`\`sh
|
|
2030
|
+
curl http://localhost:3000/api/status
|
|
1709
2031
|
\`\`\`
|
|
1710
2032
|
`
|
|
1711
2033
|
};
|