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/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 `# Lakebed App Instructions
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
- This is a Lakebed capsule. Build the app inside this directory using the Lakebed v0 contract.
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
- ## File Layout
1789
+ ## Default project structure
1516
1790
 
1517
- - \`server/index.ts\`: schema, queries, and mutations.
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
- ## Rules
1817
+ ## Additional resources
1544
1818
 
1545
- - Use \`lakebed/server\` only from \`server/index.ts\`.
1546
- - Use \`lakebed/client\` only from \`client/index.tsx\`.
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
- export function App() {
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
- <main className="min-h-screen bg-black px-6 py-10 text-white">
1658
- <section className="mx-auto max-w-2xl">
1659
- <div className="mb-3 flex items-center justify-between gap-3">
1660
- <div className="flex min-w-0 items-center gap-2">
1661
- {!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
1662
- <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
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
- {!auth.isLoading && auth.isGuest ? (
1665
- <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" />
1666
- ) : !auth.isLoading ? (
1667
- <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1668
- Sign out
1669
- </button>
1670
- ) : null}
1671
- </div>
1672
- <h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
1673
- <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
1674
- <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" />
1675
- <button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
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
- pnpm lakebed dev ${name}
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
  };