lakebed 0.0.17 → 0.0.19

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
 
@@ -455,6 +456,177 @@ function sendJson(ws, message) {
455
456
  ws.send(JSON.stringify(message));
456
457
  }
457
458
 
459
+ async function readRequestBody(req, maxBytes = endpointBodyMaxBytes) {
460
+ const chunks = [];
461
+ let total = 0;
462
+ for await (const chunk of req) {
463
+ total += chunk.byteLength;
464
+ if (total > maxBytes) {
465
+ throw new Error(`Request body exceeds ${maxBytes} bytes.`);
466
+ }
467
+ chunks.push(chunk);
468
+ }
469
+ return Buffer.concat(chunks);
470
+ }
471
+
472
+ function headersFromNodeRequest(headers) {
473
+ const clean = {};
474
+ for (const [name, value] of Object.entries(headers ?? {})) {
475
+ if (Array.isArray(value)) {
476
+ clean[name.toLowerCase()] = value.join(", ");
477
+ } else if (value !== undefined) {
478
+ clean[name.toLowerCase()] = String(value);
479
+ }
480
+ }
481
+ return clean;
482
+ }
483
+
484
+ function createEndpointRequest({ body, headers, method, url }) {
485
+ const requestUrl = new URL(url.href);
486
+ const headerMap = new Map(Object.entries(headersFromNodeRequest(headers)));
487
+ const requestBody = Buffer.from(body);
488
+
489
+ return {
490
+ headers: {
491
+ entries() {
492
+ return headerMap.entries();
493
+ },
494
+ get(name) {
495
+ return headerMap.get(String(name).toLowerCase()) ?? null;
496
+ },
497
+ has(name) {
498
+ return headerMap.has(String(name).toLowerCase());
499
+ }
500
+ },
501
+ method: String(method ?? "GET").toUpperCase(),
502
+ path: requestUrl.pathname,
503
+ query: new URLSearchParams(requestUrl.searchParams),
504
+ url: requestUrl.href,
505
+ async bytes() {
506
+ return new Uint8Array(requestBody);
507
+ },
508
+ async json() {
509
+ return JSON.parse(requestBody.toString("utf8"));
510
+ },
511
+ async text() {
512
+ return requestBody.toString("utf8");
513
+ }
514
+ };
515
+ }
516
+
517
+ function endpointBodyToBuffer(body) {
518
+ if (body === undefined || body === null) {
519
+ return Buffer.alloc(0);
520
+ }
521
+ if (Buffer.isBuffer(body)) {
522
+ return body;
523
+ }
524
+ if (typeof body === "string") {
525
+ return Buffer.from(body, "utf8");
526
+ }
527
+ if (body instanceof ArrayBuffer) {
528
+ return Buffer.from(body);
529
+ }
530
+ if (ArrayBuffer.isView(body)) {
531
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
532
+ }
533
+ return Buffer.from(JSON.stringify(body ?? null), "utf8");
534
+ }
535
+
536
+ function endpointStatus(status, fallback = 200) {
537
+ const parsed = Number(status);
538
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : fallback;
539
+ }
540
+
541
+ function headersToObject(headers = {}) {
542
+ if (!headers) {
543
+ return {};
544
+ }
545
+ if (typeof headers.entries === "function") {
546
+ return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
547
+ }
548
+ if (Array.isArray(headers)) {
549
+ return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
550
+ }
551
+ if (typeof headers === "object") {
552
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [String(key), String(value)]));
553
+ }
554
+ return {};
555
+ }
556
+
557
+ async function normalizeEndpointResponse(result) {
558
+ if (result === undefined || result === null) {
559
+ return { body: Buffer.alloc(0), headers: {}, status: 204 };
560
+ }
561
+
562
+ if (typeof Response !== "undefined" && result instanceof Response) {
563
+ return {
564
+ body: Buffer.from(await result.arrayBuffer()),
565
+ headers: headersToObject(result.headers),
566
+ status: endpointStatus(result.status)
567
+ };
568
+ }
569
+
570
+ if (typeof result === "object" && result?.kind === "response") {
571
+ return {
572
+ body: endpointBodyToBuffer(result.body),
573
+ headers: headersToObject(result.headers),
574
+ status: endpointStatus(result.status)
575
+ };
576
+ }
577
+
578
+ if (typeof result === "string") {
579
+ return {
580
+ body: endpointBodyToBuffer(result),
581
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
582
+ status: 200
583
+ };
584
+ }
585
+
586
+ return {
587
+ body: endpointBodyToBuffer(result),
588
+ headers: { "Content-Type": "application/json; charset=utf-8" },
589
+ status: 200
590
+ };
591
+ }
592
+
593
+ function sanitizeEndpointResponseHeaders(headers = {}) {
594
+ const blocked = new Set(["connection", "content-length", "date", "keep-alive", "transfer-encoding", "upgrade"]);
595
+ const clean = {};
596
+ for (const [rawName, rawValue] of Object.entries(headers)) {
597
+ const name = String(rawName);
598
+ const lower = name.toLowerCase();
599
+ if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/i.test(name) || blocked.has(lower)) {
600
+ continue;
601
+ }
602
+ clean[name] = String(rawValue);
603
+ }
604
+ return clean;
605
+ }
606
+
607
+ function sendEndpointResponse(res, response) {
608
+ const body = response.body ?? Buffer.alloc(0);
609
+ res.writeHead(response.status, {
610
+ ...sanitizeEndpointResponseHeaders(response.headers),
611
+ "Content-Length": String(body.byteLength)
612
+ });
613
+ res.end(body);
614
+ }
615
+
616
+ function endpointDefinitions(app) {
617
+ return Object.entries(app.endpoints ?? {}).map(([name, endpoint]) => ({
618
+ handler: endpoint?.handler ?? endpoint,
619
+ method: String(endpoint?.method ?? "").toUpperCase(),
620
+ name,
621
+ path: String(endpoint?.path ?? "")
622
+ }));
623
+ }
624
+
625
+ function findEndpoint(app, method, path) {
626
+ const requestMethod = String(method ?? "GET").toUpperCase();
627
+ return endpointDefinitions(app).find((endpoint) => endpoint.method === requestMethod && endpoint.path === path) ?? null;
628
+ }
629
+
458
630
  async function capsuleFileFingerprint(rootDir, dir = rootDir, entries = []) {
459
631
  const dirEntries = await readdir(dir, { withFileTypes: true });
460
632
 
@@ -518,6 +690,25 @@ async function runMutation({ app, stateCell, auth, logs, env, name, args }) {
518
690
  );
519
691
  }
520
692
 
693
+ async function runEndpoint({ app, stateCell, auth, logs, env, endpoint, request }) {
694
+ if (typeof endpoint.handler !== "function") {
695
+ throw new Error(`Unknown endpoint: ${endpoint.name}`);
696
+ }
697
+
698
+ return stateCell.transaction(async (db) => {
699
+ const result = await endpoint.handler(
700
+ {
701
+ auth,
702
+ db,
703
+ env,
704
+ log: logs.createLogger()
705
+ },
706
+ request
707
+ );
708
+ return normalizeEndpointResponse(result);
709
+ });
710
+ }
711
+
521
712
  export async function startDevServer({
522
713
  capsuleDir,
523
714
  sourceStore,
@@ -596,6 +787,38 @@ export async function startDevServer({
596
787
  return;
597
788
  }
598
789
 
790
+ const endpoint = findEndpoint(currentBuild.app, req.method, requestUrl.pathname);
791
+ if (endpoint) {
792
+ const auth = await resolveAuthFromUrl({
793
+ defaultAuth,
794
+ onError: (error) => {
795
+ logs.append("warn", "endpoint auth verification failed", { error: error instanceof Error ? error.message : String(error) });
796
+ },
797
+ origin: requestOrigin(req),
798
+ shooBaseUrl,
799
+ url: requestUrl
800
+ });
801
+ const body = await readRequestBody(req);
802
+ const endpointRequest = createEndpointRequest({
803
+ body,
804
+ headers: req.headers,
805
+ method: req.method,
806
+ url: requestUrl
807
+ });
808
+ const { result: response } = await runEndpoint({
809
+ app: currentBuild.app,
810
+ auth,
811
+ endpoint,
812
+ env: currentBuild.env,
813
+ logs,
814
+ request: endpointRequest,
815
+ stateCell
816
+ });
817
+ sendEndpointResponse(res, response);
818
+ await publishAll();
819
+ return;
820
+ }
821
+
599
822
  res.writeHead(404);
600
823
  res.end("Not found");
601
824
  } catch (error) {
@@ -844,7 +1067,7 @@ export default capsule({
844
1067
  <p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
845
1068
  <h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
846
1069
  <p className="mt-4 text-neutral-300">
847
- This capsule uses ${feature}. Claim this deploy, then run lakebed deploy again to publish the app.
1070
+ This capsule uses ${feature}. Claim this deploy, then run npx lakebed deploy again to publish the app.
848
1071
  </p>
849
1072
  </section>
850
1073
  </main>
@@ -950,7 +1173,7 @@ function claimUrlFromDeployMetadata(metadata) {
950
1173
  }
951
1174
 
952
1175
  function claimCommandText({ api, capsuleArg }) {
953
- const parts = ["lakebed", "claim"];
1176
+ const parts = ["npx", "lakebed", "claim"];
954
1177
  if (capsuleArg) {
955
1178
  parts.push(capsuleArg);
956
1179
  }
@@ -1088,7 +1311,7 @@ async function readResponseJson(response) {
1088
1311
 
1089
1312
  async function deployCommand(args) {
1090
1313
  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.");
1314
+ throw new Error("npx lakebed deploy no longer accepts --ttl. Deploy expiry is set by the server.");
1092
1315
  }
1093
1316
 
1094
1317
  const [capsuleArg] = positionals(args);
@@ -1115,7 +1338,7 @@ async function deployCommand(args) {
1115
1338
  if (!currentDeploy?.claimed && hasServerEnvValues) {
1116
1339
  if (canUpdate && currentDeploy) {
1117
1340
  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.`
1341
+ `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
1342
  );
1120
1343
  }
1121
1344
  try {
@@ -1132,7 +1355,7 @@ async function deployCommand(args) {
1132
1355
  } catch (error) {
1133
1356
  if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
1134
1357
  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.`
1358
+ `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run npx lakebed deploy again to use server-side fetch.`
1136
1359
  );
1137
1360
  }
1138
1361
  if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
@@ -1214,7 +1437,7 @@ async function deployCommand(args) {
1214
1437
  if (deployed.claimUrl) {
1215
1438
  console.log(`Claim: ${claimCommandText({ api, capsuleArg })}`);
1216
1439
  }
1217
- console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
1440
+ console.log(`Inspect: npx lakebed inspect ${deployed.deployId}`);
1218
1441
  if (deployed.inspectPolicy === "public") {
1219
1442
  console.log("Inspect policy: public - data and logs are readable by anyone with the app URL.");
1220
1443
  }
@@ -1232,7 +1455,7 @@ async function deployCommand(args) {
1232
1455
  }
1233
1456
  if (envelope.claimRequired) {
1234
1457
  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.`);
1458
+ console.log(`Run ${claimCommandText({ api, capsuleArg })}, then run npx lakebed deploy again.`);
1236
1459
  }
1237
1460
  }
1238
1461
 
@@ -1243,7 +1466,7 @@ async function claimCommand(args) {
1243
1466
  const metadata = await readDeployMetadata(capsuleDir);
1244
1467
 
1245
1468
  if (!metadata) {
1246
- throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
1469
+ throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
1247
1470
  }
1248
1471
 
1249
1472
  if (metadata.api !== api) {
@@ -1307,7 +1530,7 @@ async function domainsCommand(args) {
1307
1530
  const api = deployApiUrl(args);
1308
1531
  const metadata = await readDeployMetadata(capsuleDir);
1309
1532
  if (!metadata) {
1310
- throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
1533
+ throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
1311
1534
  }
1312
1535
  if (metadata.api !== api) {
1313
1536
  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 +1655,10 @@ async function inspectCommand(args) {
1432
1655
  if (Array.isArray(manifest.mutations)) {
1433
1656
  console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
1434
1657
  }
1658
+ if (Array.isArray(manifest.endpoints)) {
1659
+ const endpoints = manifest.endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path} -> ${endpoint.name}`);
1660
+ console.log(`Endpoints: ${endpoints.join(", ") || "(none)"}`);
1661
+ }
1435
1662
  if (Array.isArray(manifest.mutationDetails) && manifest.mutationDetails.length > 0) {
1436
1663
  console.log("Mutation runtime:");
1437
1664
  for (const detail of manifest.mutationDetails) {
@@ -1508,13 +1735,34 @@ async function dbCommand(args) {
1508
1735
  }
1509
1736
 
1510
1737
  function agentInstructionsTemplate() {
1511
- return `# Lakebed App Instructions
1738
+ return `# Building with Lakebed.
1512
1739
 
1513
- This is a Lakebed capsule. Build the app inside this directory using the Lakebed v0 contract.
1740
+ 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.
1514
1741
 
1515
- ## File Layout
1742
+ 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.
1743
+
1744
+ ## Hard rules
1745
+
1746
+ - No installing node modules. You can use the built-in APIs. Write TypeScript for anything that is not included.
1747
+ - Lakebed CLI should always be run with \`npx lakebed [command]\`. It is not a global. Launch with \`npx\` always.
1748
+ - All client code goes in the \`client\` directory, and all server code goes in the \`server\` directory. Shared code can go in \`shared\`.
1749
+ - Use \`lakebed/server\` only from \`server/*.ts\`.
1750
+ - Use \`lakebed/client\` only from \`client/*.tsx\`.
1751
+ - 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.
1752
+ - Styling must be done via raw CSS or Tailwind classes in the JSX.
1753
+ - Do not add a CSS, PostCSS, or Tailwind build pipeline. They are built in.
1754
+ - There is no file based routing. You can define routes yourself through typescript.
1755
+ - All imports must be from Lakebed or from relative paths.
1756
+ - Do not use Node built-ins in app code.
1757
+ - Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
1758
+ - Read server-only environment variables through \`ctx.env\`; define them in \`.env.lakebed.server\`.
1759
+ - Auth can be added with a Google sign-in using \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
1760
+ - Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
1761
+ - 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\`.
1516
1762
 
1517
- - \`server/index.ts\`: schema, queries, and mutations.
1763
+ ## Default project structure
1764
+
1765
+ - \`server/index.ts\`: schema, queries, mutations, and external endpoints.
1518
1766
  - \`client/index.tsx\`: Preact UI entrypoint.
1519
1767
  - \`shared/\`: pure TypeScript shared by client and server.
1520
1768
 
@@ -1523,35 +1771,27 @@ This is a Lakebed capsule. Build the app inside this directory using the Lakebed
1523
1771
  Run locally:
1524
1772
 
1525
1773
  \`\`\`sh
1526
- lakebed dev
1774
+ npx lakebed dev
1527
1775
  \`\`\`
1528
1776
 
1529
1777
  Deploy:
1530
1778
 
1531
1779
  \`\`\`sh
1532
- lakebed deploy
1780
+ npx lakebed deploy
1533
1781
  \`\`\`
1534
1782
 
1535
- Inspect local state while \`lakebed dev\` is running:
1783
+ Inspect local state while \`npx lakebed dev\` is running:
1536
1784
 
1537
1785
  \`\`\`sh
1538
- lakebed db list --port 3000
1539
- lakebed db dump --port 3000
1540
- lakebed logs --port 3000
1786
+ npx lakebed db list --port 3000
1787
+ npx lakebed db dump --port 3000
1788
+ npx lakebed logs --port 3000
1541
1789
  \`\`\`
1542
1790
 
1543
- ## Rules
1791
+ ## Additional resources
1544
1792
 
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.
1793
+ - [Lakebed docs](https://docs.lakebed.dev/)
1794
+ - [Capsule API docs](https://docs.lakebed.dev/capsule-api/)
1555
1795
 
1556
1796
  ## Current Limits
1557
1797
 
@@ -1561,7 +1801,8 @@ lakebed logs --port 3000
1561
1801
  - No file storage.
1562
1802
  - No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
1563
1803
  - Non-empty \`.env.lakebed.server\` files sync only after a deploy is claimed.
1564
- - Local state resets when \`lakebed dev\` restarts.
1804
+ - Local state resets when \`npx lakebed dev\` restarts.
1805
+ - All production deploys are on 'lakebed.app'
1565
1806
  `;
1566
1807
  }
1567
1808
 
@@ -1609,11 +1850,35 @@ export default capsule({
1609
1850
  "client/index.tsx": `import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
1610
1851
  import { cleanTodoText, type Todo } from "../shared/todo";
1611
1852
 
1853
+ function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
1854
+ const initial = label.trim().slice(0, 1).toUpperCase() || "?";
1855
+
1856
+ if (picture) {
1857
+ return (
1858
+ <img
1859
+ alt=""
1860
+ className="h-7 w-7 shrink-0 rounded-full border border-neutral-800 bg-neutral-900 object-cover"
1861
+ referrerPolicy="no-referrer"
1862
+ src={picture}
1863
+ />
1864
+ );
1865
+ }
1866
+
1867
+ return (
1868
+ <span
1869
+ aria-hidden="true"
1870
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-800 bg-neutral-900 text-xs font-medium text-neutral-300"
1871
+ >
1872
+ {initial}
1873
+ </span>
1874
+ );
1875
+ }
1876
+
1612
1877
  export function App() {
1613
1878
  const auth = useAuth();
1614
1879
  const todos = useQuery<Todo[]>("todos");
1615
1880
  const addTodo = useMutation<[text: string], void>("addTodo");
1616
- const authLabel = auth.email ?? auth.displayName;
1881
+ const authLabel = auth.displayName;
1617
1882
  const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
1618
1883
 
1619
1884
  async function onSubmit(event: SubmitEvent) {
@@ -1633,11 +1898,14 @@ export function App() {
1633
1898
  <main className="min-h-screen bg-black px-6 py-10 text-white">
1634
1899
  <section className="mx-auto max-w-2xl">
1635
1900
  <div className="mb-3 flex items-center justify-between gap-3">
1636
- <p className="font-mono text-sm text-neutral-500">{authStatus}</p>
1901
+ <div className="flex min-w-0 items-center gap-2">
1902
+ {!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
1903
+ <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
1904
+ </div>
1637
1905
  {!auth.isLoading && auth.isGuest ? (
1638
- <SignInWithGoogle className="border border-neutral-700 px-3 py-1.5 text-sm font-medium text-neutral-200 hover:border-white hover:text-white" />
1906
+ <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" />
1639
1907
  ) : !auth.isLoading ? (
1640
- <button className="text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1908
+ <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1641
1909
  Sign out
1642
1910
  </button>
1643
1911
  ) : null}
@@ -1678,7 +1946,7 @@ export function cleanTodoText(value: string): string {
1678
1946
  Run this Lakebed capsule:
1679
1947
 
1680
1948
  \`\`\`sh
1681
- pnpm lakebed dev ${name}
1949
+ npx lakebed dev
1682
1950
  \`\`\`
1683
1951
  `
1684
1952
  };
package/src/client.d.ts CHANGED
@@ -9,7 +9,6 @@ export type Auth = {
9
9
  isLoading?: boolean;
10
10
  email?: string;
11
11
  emailVerified?: boolean;
12
- name?: string;
13
12
  picture?: string;
14
13
  };
15
14
 
package/src/client.js CHANGED
@@ -349,13 +349,13 @@ function createGoogleAuthFromToken(token) {
349
349
  return null;
350
350
  }
351
351
 
352
+ const displayName = typeof claims.name === "string" && claims.name.trim() ? claims.name.trim() : "Google User";
352
353
  return {
353
- displayName: claims.name ?? claims.email ?? "Google User",
354
+ displayName,
354
355
  email: claims.email,
355
356
  emailVerified: claims.email_verified,
356
357
  isAuthenticated: true,
357
358
  isGuest: false,
358
- name: claims.name,
359
359
  picture: claims.picture,
360
360
  provider: "google",
361
361
  userId: `google:${pairwiseSub}`
package/src/server.d.ts CHANGED
@@ -17,7 +17,6 @@ export type AuthContext = {
17
17
  isAuthenticated: boolean;
18
18
  email?: string;
19
19
  emailVerified?: boolean;
20
- name?: string;
21
20
  picture?: string;
22
21
  };
23
22
 
@@ -50,11 +49,60 @@ export type ServerContext = {
50
49
  log: LogContext;
51
50
  };
52
51
 
52
+ export type EndpointRoute = {
53
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD" | (string & {});
54
+ path: `/${string}`;
55
+ };
56
+
57
+ export type EndpointHeaders = {
58
+ get(name: string): string | null;
59
+ has(name: string): boolean;
60
+ entries(): IterableIterator<[string, string]>;
61
+ };
62
+
63
+ export type EndpointRequest = {
64
+ method: string;
65
+ path: string;
66
+ url: string;
67
+ headers: EndpointHeaders;
68
+ query: URLSearchParams;
69
+ text(): Promise<string>;
70
+ json<T = unknown>(): Promise<T>;
71
+ bytes(): Promise<Uint8Array>;
72
+ };
73
+
74
+ export type EndpointResponse = {
75
+ kind: "response";
76
+ status: number;
77
+ headers?: Record<string, string>;
78
+ body?: string | Uint8Array | ArrayBuffer;
79
+ };
80
+
81
+ export type EndpointResponseOptions = {
82
+ status?: number;
83
+ headers?: Record<string, string>;
84
+ };
85
+
86
+ export type EndpointDefinition<TResult = EndpointResponse | string | unknown | void> = {
87
+ kind: "endpoint";
88
+ method: string;
89
+ path: string;
90
+ handler: (ctx: ServerContext, req: EndpointRequest) => TResult | Promise<TResult>;
91
+ };
92
+
53
93
  export function capsule<T>(definition: T): T;
54
94
  export function query<T>(handler: (ctx: ServerContext) => T): (ctx: ServerContext) => T;
55
95
  export function mutation<TArgs extends unknown[], TResult>(
56
96
  handler: (ctx: ServerContext, ...args: TArgs) => TResult
57
97
  ): (ctx: ServerContext, ...args: TArgs) => TResult;
98
+ export function endpoint<TResult>(
99
+ route: EndpointRoute,
100
+ handler: (ctx: ServerContext, req: EndpointRequest) => TResult | Promise<TResult>
101
+ ): EndpointDefinition<TResult>;
102
+ export function json(value: unknown, options?: EndpointResponseOptions): EndpointResponse;
103
+ export function text(value: unknown, options?: EndpointResponseOptions): EndpointResponse;
104
+ export function empty(options?: EndpointResponseOptions): EndpointResponse;
105
+ export function redirect(url: string, options?: EndpointResponseOptions): EndpointResponse;
58
106
  export function table(fields: Record<string, Field<unknown>>): TableDefinition;
59
107
  export function string(): Field<string>;
60
108
  export function boolean(): Field<boolean>;
package/src/server.js CHANGED
@@ -10,6 +10,59 @@ export function mutation(handler) {
10
10
  return handler;
11
11
  }
12
12
 
13
+ export function endpoint(route, handler) {
14
+ return {
15
+ handler,
16
+ kind: "endpoint",
17
+ method: String(route?.method ?? "").toUpperCase(),
18
+ path: String(route?.path ?? "")
19
+ };
20
+ }
21
+
22
+ function response(body, { headers = {}, status = 200 } = {}) {
23
+ return {
24
+ body,
25
+ headers,
26
+ kind: "response",
27
+ status
28
+ };
29
+ }
30
+
31
+ export function json(value, options = {}) {
32
+ return response(JSON.stringify(value ?? null), {
33
+ ...options,
34
+ headers: {
35
+ "Content-Type": "application/json; charset=utf-8",
36
+ ...(options.headers ?? {})
37
+ }
38
+ });
39
+ }
40
+
41
+ export function text(value, options = {}) {
42
+ return response(String(value ?? ""), {
43
+ ...options,
44
+ headers: {
45
+ "Content-Type": "text/plain; charset=utf-8",
46
+ ...(options.headers ?? {})
47
+ }
48
+ });
49
+ }
50
+
51
+ export function empty(options = {}) {
52
+ return response("", { status: 204, ...options });
53
+ }
54
+
55
+ export function redirect(url, options = {}) {
56
+ return response("", {
57
+ status: 302,
58
+ ...options,
59
+ headers: {
60
+ Location: String(url),
61
+ ...(options.headers ?? {})
62
+ }
63
+ });
64
+ }
65
+
13
66
  function field(kind) {
14
67
  return {
15
68
  kind,