lakebed 0.0.19 → 0.0.21

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 CHANGED
@@ -142,7 +142,7 @@ npx lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app
142
142
  npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
143
143
  npx lakebed claim [capsule-dir] [--api <url>] [--json]
144
144
  npx lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
145
- npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
145
+ npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--dashboard-root-url <url>] [--app-base-domain <domain>] [--role all|api-dashboard|runner]
146
146
  npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
147
147
  npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
148
148
  npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
@@ -179,10 +179,19 @@ npx lakebed deploy --api http://localhost:8787
179
179
  In production, set `PUBLIC_ROOT_URL` to the deploy API origin and `LAKEBED_APP_BASE_DOMAIN` to the app domain without the wildcard prefix:
180
180
 
181
181
  ```sh
182
- PUBLIC_ROOT_URL=https://api.lakebed.app
182
+ PUBLIC_ROOT_URL=https://api.lakebed.dev
183
+ LAKEBED_DASHBOARD_ROOT_URL=https://dashboard.lakebed.dev
183
184
  LAKEBED_APP_BASE_DOMAIN=lakebed.app
184
185
  ```
185
186
 
187
+ The staged Railway split uses dedicated service entrypoints:
188
+
189
+ ```sh
190
+ node packages/lakebed/src/services/all-in-one.js
191
+ node packages/lakebed/src/services/api-dashboard.js
192
+ node packages/lakebed/src/services/capsule-runner.js
193
+ ```
194
+
186
195
  With a verified `*.lakebed.app` custom domain on the runner, deploy responses use `https://<slug>.lakebed.app`.
187
196
 
188
197
  Anonymous deploy creation is relaxed for local `localhost` runners. When `PUBLIC_ROOT_URL` points at a non-local origin, the runner defaults to 50 anonymous deploy creates per forwarded client per UTC day and 5,000 globally. Override those with:
@@ -205,7 +214,7 @@ LAKEBED_ANONYMOUS_CLEANUP_RETENTION=7d
205
214
  LAKEBED_ANONYMOUS_CLEANUP_INTERVAL=1h
206
215
  ```
207
216
 
208
- Deploy responses include claim metadata. Configure GitHub OAuth on the runner, then run `npx lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
217
+ Deploy responses include claim metadata. Configure GitHub OAuth on the API and dashboard service, then run `npx lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
209
218
 
210
219
  ```sh
211
220
  LAKEBED_GITHUB_CLIENT_ID=...
@@ -214,7 +223,7 @@ LAKEBED_SESSION_SECRET=...
214
223
  LAKEBED_SERVER_ENV_SECRET=...
215
224
  ```
216
225
 
217
- Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys and do not expire. Anonymous deploys cannot use outbound `fetch` or hosted server env; after a deploy is claimed, `npx lakebed deploy` can update it with a source-backed server artifact that supports async handlers, server-side fetch, and `.env.lakebed.server` sync. If the first deploy already needs server-side `fetch` or server env, `npx lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the `npx lakebed claim` command. Run that command, then run `npx lakebed deploy` again to publish the real source-backed app. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
226
+ Claimed deploys are listed at `/deploys` on the dashboard origin. They keep the same resource limits as anonymous deploys and do not expire. Anonymous deploys cannot use outbound `fetch` or hosted server env; after a deploy is claimed, `npx lakebed deploy` can update it with a source-backed server artifact that supports async handlers, server-side fetch, and `.env.lakebed.server` sync. If the first deploy already needs server-side `fetch` or server env, `npx lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the `npx lakebed claim` command. Run that command, then run `npx lakebed deploy` again to publish the real source-backed app. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
218
227
 
219
228
  Hosted inspection is private by default. `npx lakebed inspect`, `npx lakebed db list`, `npx lakebed db dump`, and `npx lakebed logs` send the saved claim token automatically when run from the capsule directory.
220
229
 
@@ -228,4 +237,4 @@ Reserved product names such as `api`, `admin`, `docs`, and `www` are rejected.
228
237
 
229
238
  ## Admin Dashboard
230
239
 
231
- Set `LAKEBED_ADMIN_PASSWORD` on the anonymous deploy runner, then open `/admin` on the runner origin. The password is exchanged for an HttpOnly cookie so the dashboard stays unlocked until the cookie expires or the password changes. The resource table can terminate active deploys while preserving their resource history, and the users table can set per-user request and mutation limit overrides for claimed deploys.
240
+ Set `LAKEBED_ADMIN_PASSWORD` on the API and dashboard service, then open `/admin` on the dashboard origin. The password is exchanged for an HttpOnly cookie so the dashboard stays unlocked until the cookie expires or the password changes. The resource table can terminate active deploys while preserving their resource history, and the users table can set per-user request and mutation limit overrides for claimed deploys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -29,6 +29,9 @@
29
29
  "src/runtime.js",
30
30
  "src/server.d.ts",
31
31
  "src/server.js",
32
+ "src/services/all-in-one.js",
33
+ "src/services/api-dashboard.js",
34
+ "src/services/capsule-runner.js",
32
35
  "src/source-runtime-loader.mjs",
33
36
  "src/source-runtime-worker.js",
34
37
  "src/source-runtime.js",
@@ -52,9 +55,6 @@
52
55
  "publishConfig": {
53
56
  "access": "public"
54
57
  },
55
- "scripts": {
56
- "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
57
- },
58
58
  "dependencies": {
59
59
  "esbuild": "^0.27.1",
60
60
  "pg": "^8.16.3",
@@ -63,5 +63,8 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/ws": "^8.18.1"
66
+ },
67
+ "scripts": {
68
+ "check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js && node --check src/services/all-in-one.js && node --check src/services/api-dashboard.js && node --check src/services/capsule-runner.js"
66
69
  }
67
- }
70
+ }
@@ -405,16 +405,47 @@ function normalizeLakebedSubdomainInput(value, appBaseDomain) {
405
405
  };
406
406
  }
407
407
 
408
- function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
409
- if (appBaseDomain) {
410
- return `https://${slug}.${appBaseDomain}`;
408
+ function normalizeServerRole(value) {
409
+ const role = String(value ?? "all").trim().toLowerCase();
410
+ if (role === "all" || role === "api-dashboard" || role === "runner") {
411
+ return role;
411
412
  }
412
413
 
413
- return `${publicRootUrl}/d/${slug}`;
414
+ throw new Error(`Unsupported LAKEBED_SERVER_ROLE: ${role}. Expected all, api-dashboard, or runner.`);
414
415
  }
415
416
 
416
- function claimUrlForDeploy({ deployId, publicRootUrl, token }) {
417
- return `${publicRootUrl}/claim/${deployId}/${token}`;
417
+ function roleServesApiDashboard(role) {
418
+ return role === "all" || role === "api-dashboard";
419
+ }
420
+
421
+ function roleServesRunner(role) {
422
+ return role === "all" || role === "runner";
423
+ }
424
+
425
+ function isApiDashboardRoute(pathname) {
426
+ return (
427
+ pathname === "/deploys" ||
428
+ pathname === "/dashboard" ||
429
+ pathname === "/auth" ||
430
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback") ||
431
+ pathname === "/admin" ||
432
+ pathname.startsWith("/admin/") ||
433
+ pathname === "/v1" ||
434
+ pathname.startsWith("/v1/") ||
435
+ pathname.startsWith("/claim/")
436
+ );
437
+ }
438
+
439
+ function appUrlForSlug({ appBaseDomain, slug }) {
440
+ if (!appBaseDomain) {
441
+ throw new Error("LAKEBED_APP_BASE_DOMAIN is required to create hosted deploy URLs.");
442
+ }
443
+
444
+ return `https://${slug}.${appBaseDomain}`;
445
+ }
446
+
447
+ function claimUrlForDeploy({ dashboardRootUrl, deployId, token }) {
448
+ return `${dashboardRootUrl}/claim/${deployId}/${token}`;
418
449
  }
419
450
 
420
451
  function inspectUrls(url) {
@@ -426,11 +457,11 @@ function inspectUrls(url) {
426
457
  };
427
458
  }
428
459
 
429
- function responseForDeploy({ deploy, token }) {
460
+ function responseForDeploy({ dashboardRootUrl, deploy, token }) {
430
461
  return {
431
462
  claimed: Boolean(deploy.ownerId),
432
463
  claimedAt: deploy.claimedAt ?? undefined,
433
- claimUrl: token ? claimUrlForDeploy({ deployId: deploy.id, publicRootUrl: deploy.publicRootUrl, token }) : undefined,
464
+ claimUrl: token ? claimUrlForDeploy({ dashboardRootUrl: dashboardRootUrl ?? deploy.publicRootUrl, deployId: deploy.id, token }) : undefined,
434
465
  deployId: deploy.id,
435
466
  expiresAt: deploy.expiresAt,
436
467
  inspect: inspectUrls(deploy.url),
@@ -474,19 +505,24 @@ function routeSystemPath(pathname) {
474
505
  return pathname;
475
506
  }
476
507
 
477
- function parsePathDeploy(url) {
478
- const parts = url.pathname.split("/").filter(Boolean);
479
- if (parts[0] !== "d" || !parts[1]) {
480
- return null;
481
- }
508
+ function wantsHtml(req) {
509
+ const accept = String(req.headers.accept ?? "");
510
+ return !accept || accept.includes("text/html");
511
+ }
482
512
 
483
- const prefix = `/d/${parts[1]}`;
484
- const appPath = url.pathname === prefix ? "/" : url.pathname.slice(prefix.length) || "/";
485
- return {
486
- appPath,
487
- basePath: prefix,
488
- slug: parts[1]
489
- };
513
+ function isReservedClientShellPath(pathname) {
514
+ return (
515
+ pathname === "/client.js" ||
516
+ pathname === "/__lakebed" ||
517
+ pathname.startsWith("/__lakebed/") ||
518
+ pathname === "/__span" ||
519
+ pathname.startsWith("/__span/") ||
520
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback")
521
+ );
522
+ }
523
+
524
+ function isClientShellRequest(req, pathname) {
525
+ return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
490
526
  }
491
527
 
492
528
  function parseHostDeploy({ appBaseDomain, host, url }) {
@@ -512,6 +548,10 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
512
548
  };
513
549
  }
514
550
 
551
+ function isDisabledPathDeployRoute(pathname) {
552
+ return pathname === "/d" || pathname.startsWith("/d/");
553
+ }
554
+
515
555
  function quotaLimitForBucket(bucket, deploy) {
516
556
  if (bucket === "mutations") {
517
557
  return deploy.limits.mutationsPerDay;
@@ -5696,7 +5736,7 @@ async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
5696
5736
  hostname: domain.hostname,
5697
5737
  deployId: domain.deployId
5698
5738
  }
5699
- : parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
5739
+ : parseHostDeploy({ appBaseDomain, host, url });
5700
5740
  if (!route) {
5701
5741
  return null;
5702
5742
  }
@@ -5914,17 +5954,23 @@ async function serveInspect({ adminPassword, artifact, currentDeveloper, deploy,
5914
5954
  export async function startAnonymousServer({
5915
5955
  adminPassword = adminPasswordFromEnv(),
5916
5956
  appBaseDomain = process.env.LAKEBED_APP_BASE_DOMAIN ?? "",
5957
+ dashboardRootUrl = process.env.LAKEBED_DASHBOARD_ROOT_URL,
5917
5958
  developerSessionSecret = process.env.LAKEBED_SESSION_SECRET ?? "",
5918
5959
  githubOAuth = githubOAuthFromEnv(),
5919
5960
  port = Number(process.env.PORT ?? 8787),
5920
5961
  publicRootUrl,
5921
5962
  quiet = false,
5963
+ role = process.env.LAKEBED_SERVER_ROLE ?? "all",
5922
5964
  shooBaseUrl = shooBaseUrlFromEnv(),
5923
5965
  sourceRuntime,
5924
5966
  store
5925
5967
  } = {}) {
5968
+ const resolvedRole = normalizeServerRole(role);
5969
+ const servesApiDashboard = roleServesApiDashboard(resolvedRole);
5970
+ const servesRunner = roleServesRunner(resolvedRole);
5926
5971
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
5927
5972
  const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
5973
+ const resolvedDashboardRootUrl = normalizePublicRootUrl(dashboardRootUrl ?? resolvedPublicRootUrl, port);
5928
5974
  const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
5929
5975
  const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
5930
5976
  const cleanupPolicy = cleanupPolicyFromEnv();
@@ -5932,7 +5978,7 @@ export async function startAnonymousServer({
5932
5978
  const resolvedDeveloperSessionSecret =
5933
5979
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
5934
5980
  const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
5935
- const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
5981
+ const resolvedSourceRuntime = sourceRuntime === undefined && servesRunner ? createSourceRuntimeFromEnv() : sourceRuntime;
5936
5982
  await resolvedStore.initialize();
5937
5983
  const subscriptions = new Map();
5938
5984
  let cleanupInterval = null;
@@ -6171,7 +6217,7 @@ export async function startAnonymousServer({
6171
6217
  return;
6172
6218
  }
6173
6219
 
6174
- if (req.method === "GET" && (requestUrl.pathname === "/deploys" || requestUrl.pathname === "/dashboard")) {
6220
+ if (servesApiDashboard && req.method === "GET" && (requestUrl.pathname === "/deploys" || requestUrl.pathname === "/dashboard")) {
6175
6221
  const authConfigured = developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret);
6176
6222
  const user = authConfigured ? currentDeveloper(req) : null;
6177
6223
  sendText(
@@ -6187,7 +6233,7 @@ export async function startAnonymousServer({
6187
6233
  return;
6188
6234
  }
6189
6235
 
6190
- if (req.method === "GET" && requestUrl.pathname === "/v1/me") {
6236
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/v1/me") {
6191
6237
  const user = currentDeveloper(req);
6192
6238
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6193
6239
  sendJson(res, 401, { error: "Developer authentication required." });
@@ -6198,7 +6244,7 @@ export async function startAnonymousServer({
6198
6244
  return;
6199
6245
  }
6200
6246
 
6201
- if (req.method === "GET" && requestUrl.pathname === "/v1/me/deploys") {
6247
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/v1/me/deploys") {
6202
6248
  const user = currentDeveloper(req);
6203
6249
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6204
6250
  sendJson(res, 401, { error: "Developer authentication required." });
@@ -6210,7 +6256,7 @@ export async function startAnonymousServer({
6210
6256
  }
6211
6257
 
6212
6258
  const developerTerminateMatch =
6213
- req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6259
+ servesApiDashboard && req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6214
6260
  if (developerTerminateMatch) {
6215
6261
  const user = currentDeveloper(req);
6216
6262
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
@@ -6246,7 +6292,7 @@ export async function startAnonymousServer({
6246
6292
  return;
6247
6293
  }
6248
6294
 
6249
- if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
6295
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/github") {
6250
6296
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6251
6297
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
6252
6298
  return;
@@ -6264,7 +6310,7 @@ export async function startAnonymousServer({
6264
6310
  );
6265
6311
  const authorizeUrl = new URL(resolvedGithubOAuth.authorizeUrl);
6266
6312
  authorizeUrl.searchParams.set("client_id", resolvedGithubOAuth.clientId);
6267
- authorizeUrl.searchParams.set("redirect_uri", githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl));
6313
+ authorizeUrl.searchParams.set("redirect_uri", githubRedirectUri(resolvedGithubOAuth, resolvedDashboardRootUrl));
6268
6314
  authorizeUrl.searchParams.set("scope", "read:user");
6269
6315
  authorizeUrl.searchParams.set("state", state);
6270
6316
  redirect(res, authorizeUrl.href, {
@@ -6273,7 +6319,7 @@ export async function startAnonymousServer({
6273
6319
  return;
6274
6320
  }
6275
6321
 
6276
- if (req.method === "GET" && requestUrl.pathname === "/auth/github/callback") {
6322
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/github/callback") {
6277
6323
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6278
6324
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
6279
6325
  return;
@@ -6319,7 +6365,7 @@ export async function startAnonymousServer({
6319
6365
  const user = await githubUserFromCode({
6320
6366
  code,
6321
6367
  githubOAuth: resolvedGithubOAuth,
6322
- redirectUri: githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl)
6368
+ redirectUri: githubRedirectUri(resolvedGithubOAuth, resolvedDashboardRootUrl)
6323
6369
  });
6324
6370
  redirect(res, normalizeReturnTo(statePayload.returnTo), {
6325
6371
  "Set-Cookie": [developerCookie(user, resolvedDeveloperSessionSecret, secure), clearOauthStateCookie(secure)]
@@ -6327,14 +6373,14 @@ export async function startAnonymousServer({
6327
6373
  return;
6328
6374
  }
6329
6375
 
6330
- if (req.method === "GET" && requestUrl.pathname === "/auth/logout") {
6376
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/logout") {
6331
6377
  redirect(res, "/deploys", {
6332
6378
  "Set-Cookie": clearDeveloperCookie(isSecureRequest(req))
6333
6379
  });
6334
6380
  return;
6335
6381
  }
6336
6382
 
6337
- const claimMatch = req.method === "GET" ? requestUrl.pathname.match(/^\/claim\/([^/]+)\/([^/]+)$/) : null;
6383
+ const claimMatch = servesApiDashboard && req.method === "GET" ? requestUrl.pathname.match(/^\/claim\/([^/]+)\/([^/]+)$/) : null;
6338
6384
  if (claimMatch) {
6339
6385
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6340
6386
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
@@ -6372,6 +6418,7 @@ export async function startAnonymousServer({
6372
6418
  }
6373
6419
 
6374
6420
  if (
6421
+ servesApiDashboard &&
6375
6422
  req.method === "GET" &&
6376
6423
  (requestUrl.pathname === "/admin" ||
6377
6424
  requestUrl.pathname === "/admin/" ||
@@ -6383,7 +6430,7 @@ export async function startAnonymousServer({
6383
6430
  return;
6384
6431
  }
6385
6432
 
6386
- if (requestUrl.pathname === "/admin/api/login" && req.method === "POST") {
6433
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/login" && req.method === "POST") {
6387
6434
  if (!isAdminConfigured(adminPassword)) {
6388
6435
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
6389
6436
  return;
@@ -6399,12 +6446,12 @@ export async function startAnonymousServer({
6399
6446
  return;
6400
6447
  }
6401
6448
 
6402
- if (requestUrl.pathname === "/admin/api/logout" && req.method === "POST") {
6449
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/logout" && req.method === "POST") {
6403
6450
  sendJson(res, 200, { ok: true }, { "Set-Cookie": adminCookie("", 0, isSecureRequest(req)) });
6404
6451
  return;
6405
6452
  }
6406
6453
 
6407
- if (requestUrl.pathname === "/admin/api/summary" && req.method === "GET") {
6454
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/summary" && req.method === "GET") {
6408
6455
  if (!isAdminConfigured(adminPassword)) {
6409
6456
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
6410
6457
  return;
@@ -6420,7 +6467,7 @@ export async function startAnonymousServer({
6420
6467
  }
6421
6468
 
6422
6469
  const adminUserDetailMatch =
6423
- req.method === "GET" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)$/) : null;
6470
+ servesApiDashboard && req.method === "GET" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)$/) : null;
6424
6471
  if (adminUserDetailMatch) {
6425
6472
  if (!isAdminConfigured(adminPassword)) {
6426
6473
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
@@ -6443,7 +6490,7 @@ export async function startAnonymousServer({
6443
6490
  }
6444
6491
 
6445
6492
  const userLimitsMatch =
6446
- req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
6493
+ servesApiDashboard && req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
6447
6494
  if (userLimitsMatch) {
6448
6495
  if (!isAdminConfigured(adminPassword)) {
6449
6496
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
@@ -6479,7 +6526,7 @@ export async function startAnonymousServer({
6479
6526
  }
6480
6527
 
6481
6528
  const terminateMatch =
6482
- req.method === "POST"
6529
+ servesApiDashboard && req.method === "POST"
6483
6530
  ? requestUrl.pathname.match(/^\/admin\/api\/deploys\/([^/]+)\/terminate$/)
6484
6531
  : null;
6485
6532
  if (terminateMatch) {
@@ -6513,7 +6560,7 @@ export async function startAnonymousServer({
6513
6560
  }
6514
6561
 
6515
6562
  const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
6516
- if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
6563
+ if (servesApiDashboard && deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
6517
6564
  const deployId = decodeURIComponent(deployDomainsMatch[1]);
6518
6565
  const currentDeploy = await resolvedStore.getDeployById(deployId);
6519
6566
  if (!currentDeploy) {
@@ -6596,7 +6643,12 @@ export async function startAnonymousServer({
6596
6643
  return;
6597
6644
  }
6598
6645
 
6599
- if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6646
+ if (servesApiDashboard && req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6647
+ if (!resolvedAppBaseDomain) {
6648
+ sendJson(res, 503, { error: "LAKEBED_APP_BASE_DOMAIN is required to create hosted deploys." });
6649
+ return;
6650
+ }
6651
+
6600
6652
  await enforceAnonymousDeployCreation(req);
6601
6653
  const body = await readJsonBody(req);
6602
6654
  const payload = validateAnonymousDeployPayload(body);
@@ -6622,11 +6674,16 @@ export async function startAnonymousServer({
6622
6674
  serverEnv: payload.serverEnv
6623
6675
  });
6624
6676
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
6625
- sendJson(res, 201, responseForDeploy({ deploy, token }));
6677
+ sendJson(res, 201, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy, token }));
6626
6678
  return;
6627
6679
  }
6628
6680
 
6629
- if ((req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
6681
+ if (servesApiDashboard && (req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
6682
+ if (!resolvedAppBaseDomain) {
6683
+ sendJson(res, 503, { error: "LAKEBED_APP_BASE_DOMAIN is required to update hosted deploys." });
6684
+ return;
6685
+ }
6686
+
6630
6687
  const deployId = requestUrl.pathname.slice("/v1/deploys/".length);
6631
6688
  const currentDeploy = await resolvedStore.getDeployById(deployId);
6632
6689
  if (!currentDeploy) {
@@ -6672,23 +6729,43 @@ export async function startAnonymousServer({
6672
6729
  await refreshDeploySubscriptions(deploy);
6673
6730
  refreshDeployClients(deploy);
6674
6731
  await publishDeploy(deploy.id);
6675
- sendJson(res, 200, responseForDeploy({ deploy }));
6732
+ sendJson(res, 200, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy }));
6676
6733
  return;
6677
6734
  }
6678
6735
 
6679
- if (req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
6736
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
6680
6737
  const id = requestUrl.pathname.slice("/v1/deploys/".length);
6681
6738
  const deploy = (await resolvedStore.getDeployById(id)) ?? (await resolvedStore.getDeployBySlug(id));
6682
6739
  if (!deploy) {
6683
6740
  sendJson(res, 404, { error: "Unknown deploy." });
6684
6741
  return;
6685
6742
  }
6686
- sendJson(res, 200, responseForDeploy({ deploy }));
6743
+ sendJson(res, 200, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy }));
6744
+ return;
6745
+ }
6746
+
6747
+ if (isDisabledPathDeployRoute(requestUrl.pathname)) {
6748
+ sendText(res, 404, "Path-based deploy URLs are no longer supported.\n", { "Content-Type": "text/plain; charset=utf-8" });
6749
+ return;
6750
+ }
6751
+
6752
+ if (!servesRunner) {
6753
+ if (req.method === "GET" && requestUrl.pathname === "/") {
6754
+ redirect(res, "/deploys");
6755
+ return;
6756
+ }
6757
+
6758
+ sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6687
6759
  return;
6688
6760
  }
6689
6761
 
6690
6762
  const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
6691
6763
  if (!loaded) {
6764
+ if (!servesApiDashboard && isApiDashboardRoute(requestUrl.pathname)) {
6765
+ sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6766
+ return;
6767
+ }
6768
+
6692
6769
  sendText(res, 200, "Lakebed anonymous deploy runner\n", { "Content-Type": "text/plain; charset=utf-8" });
6693
6770
  return;
6694
6771
  }
@@ -6771,6 +6848,14 @@ export async function startAnonymousServer({
6771
6848
  return;
6772
6849
  }
6773
6850
 
6851
+ if (isClientShellRequest(req, appPath)) {
6852
+ sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { clientBundleHash: loaded.deploy.clientBundleHash, shooBaseUrl }), {
6853
+ "Cache-Control": "no-store",
6854
+ "Content-Type": "text/html; charset=utf-8"
6855
+ });
6856
+ return;
6857
+ }
6858
+
6774
6859
  sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6775
6860
  } catch (error) {
6776
6861
  if (isQuotaError(error)) {
@@ -6891,6 +6976,11 @@ export async function startAnonymousServer({
6891
6976
  });
6892
6977
 
6893
6978
  server.on("upgrade", async (req, socket, head) => {
6979
+ if (!servesRunner) {
6980
+ socket.destroy();
6981
+ return;
6982
+ }
6983
+
6894
6984
  const host = req.headers.host ?? "localhost";
6895
6985
  const requestUrl = new URL(req.url ?? "/", `http://${host}`);
6896
6986
  const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
@@ -6943,8 +7033,10 @@ export async function startAnonymousServer({
6943
7033
 
6944
7034
  return {
6945
7035
  appBaseDomain: resolvedAppBaseDomain,
7036
+ dashboardRootUrl: resolvedDashboardRootUrl,
6946
7037
  port,
6947
7038
  publicRootUrl: resolvedPublicRootUrl,
7039
+ role: resolvedRole,
6948
7040
  store: resolvedStore,
6949
7041
  url: resolvedPublicRootUrl,
6950
7042
  async close() {
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
- import { createServer } from "node:http";
3
+ import { createServer, request as httpRequest } from "node:http";
4
+ import { request as httpsRequest } from "node:https";
4
5
  import { existsSync, realpathSync } from "node:fs";
5
6
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
6
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -29,7 +30,7 @@ const root = process.cwd();
29
30
  const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
31
  const packageNodeModules = resolve(packageDir, "node_modules");
31
32
  const sourceNamespace = "lakebed-source";
32
- const defaultDeployApiUrl = "https://api.lakebed.app";
33
+ const defaultDeployApiUrl = "https://api.lakebed.dev";
33
34
  const execFileAsync = promisify(execFile);
34
35
  const endpointBodyMaxBytes = 2 * 1024 * 1024;
35
36
 
@@ -44,7 +45,7 @@ Usage:
44
45
  npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
45
46
  npx lakebed claim [capsule-dir] [--api <url>] [--json]
46
47
  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 anonymous-server [--port 8787] [--public-root-url <url>] [--dashboard-root-url <url>] [--app-base-domain <domain>] [--role all|api-dashboard|runner]
48
49
  npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
49
50
  npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
50
51
  npx lakebed auth as <name>
@@ -69,10 +70,12 @@ const optionsWithValues = new Set([
69
70
  "--app-base-domain",
70
71
  "--base-port",
71
72
  "--count",
73
+ "--dashboard-root-url",
72
74
  "--inspect-token",
73
75
  "--out",
74
76
  "--port",
75
77
  "--public-root-url",
78
+ "--role",
76
79
  "--target",
77
80
  "--template"
78
81
  ]);
@@ -452,6 +455,26 @@ function html(title, { shooBaseUrl } = {}) {
452
455
  </html>`;
453
456
  }
454
457
 
458
+ function wantsHtml(req) {
459
+ const accept = String(req.headers.accept ?? "");
460
+ return !accept || accept.includes("text/html");
461
+ }
462
+
463
+ function isReservedClientShellPath(pathname) {
464
+ return (
465
+ pathname === "/client.js" ||
466
+ pathname === "/__lakebed" ||
467
+ pathname.startsWith("/__lakebed/") ||
468
+ pathname === "/__span" ||
469
+ pathname.startsWith("/__span/") ||
470
+ (pathname.startsWith("/auth/") && pathname !== "/auth/callback")
471
+ );
472
+ }
473
+
474
+ function isClientShellRequest(req, pathname) {
475
+ return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
476
+ }
477
+
455
478
  function sendJson(ws, message) {
456
479
  ws.send(JSON.stringify(message));
457
480
  }
@@ -819,6 +842,12 @@ export async function startDevServer({
819
842
  return;
820
843
  }
821
844
 
845
+ if (isClientShellRequest(req, requestUrl.pathname)) {
846
+ res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "text/html; charset=utf-8" });
847
+ res.end(html(currentBuild.app.name ?? "Lakebed Capsule", { shooBaseUrl }));
848
+ return;
849
+ }
850
+
822
851
  res.writeHead(404);
823
852
  res.end("Not found");
824
853
  } catch (error) {
@@ -1564,8 +1593,10 @@ async function anonymousServerCommand(args) {
1564
1593
  const port = readNumberArg(args, "--port", Number(process.env.PORT ?? 8787));
1565
1594
  await startAnonymousServer({
1566
1595
  appBaseDomain: readArg(args, "--app-base-domain", process.env.LAKEBED_APP_BASE_DOMAIN ?? ""),
1596
+ dashboardRootUrl: readArg(args, "--dashboard-root-url", process.env.LAKEBED_DASHBOARD_ROOT_URL),
1567
1597
  port,
1568
- publicRootUrl: readArg(args, "--public-root-url", process.env.PUBLIC_ROOT_URL ?? `http://localhost:${port}`)
1598
+ publicRootUrl: readArg(args, "--public-root-url", process.env.PUBLIC_ROOT_URL ?? `http://localhost:${port}`),
1599
+ role: readArg(args, "--role", process.env.LAKEBED_SERVER_ROLE ?? "all")
1569
1600
  });
1570
1601
  await new Promise(() => {});
1571
1602
  }
@@ -1594,6 +1625,61 @@ async function resolveDeployUrl(target, args, metadata) {
1594
1625
  }
1595
1626
  }
1596
1627
 
1628
+ async function resolveHostedTarget(target, args, metadata) {
1629
+ if (!target) {
1630
+ throw new Error("Expected a deploy ID or URL.");
1631
+ }
1632
+
1633
+ try {
1634
+ const url = new URL(target);
1635
+ return { api: "", url: url.href.replace(/\/+$/g, "") };
1636
+ } catch {
1637
+ const api = deployLookupApiUrl(target, args, metadata);
1638
+ const response = await fetch(`${api}/v1/deploys/${encodeURIComponent(target)}`);
1639
+ const deploy = await readResponseJson(response);
1640
+ return { api, url: deploy.url.replace(/\/+$/g, "") };
1641
+ }
1642
+ }
1643
+
1644
+ function isLocalApiUrl(value) {
1645
+ try {
1646
+ const { hostname } = new URL(value);
1647
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
1648
+ } catch {
1649
+ return false;
1650
+ }
1651
+ }
1652
+
1653
+ async function requestWithHostHeader(url, headers) {
1654
+ const requestUrl = new URL(url);
1655
+ const transport = requestUrl.protocol === "https:" ? httpsRequest : httpRequest;
1656
+ return new Promise((resolveRequest, rejectRequest) => {
1657
+ const req = transport(
1658
+ {
1659
+ headers,
1660
+ hostname: requestUrl.hostname,
1661
+ method: "GET",
1662
+ path: `${requestUrl.pathname}${requestUrl.search}`,
1663
+ port: requestUrl.port
1664
+ },
1665
+ (res) => {
1666
+ const chunks = [];
1667
+ res.on("data", (chunk) => chunks.push(chunk));
1668
+ res.on("end", () => {
1669
+ const body = Buffer.concat(chunks).toString("utf8");
1670
+ resolveRequest({
1671
+ ok: (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300,
1672
+ status: res.statusCode ?? 500,
1673
+ text: async () => body
1674
+ });
1675
+ });
1676
+ }
1677
+ );
1678
+ req.on("error", rejectRequest);
1679
+ req.end();
1680
+ });
1681
+ }
1682
+
1597
1683
  function inspectTokenForHostedTarget({ args, metadata, target, url }) {
1598
1684
  const explicitToken = readArg(args, "--inspect-token", process.env.LAKEBED_INSPECT_TOKEN ?? "");
1599
1685
  if (explicitToken) {
@@ -1616,11 +1702,22 @@ function inspectTokenForHostedTarget({ args, metadata, target, url }) {
1616
1702
 
1617
1703
  async function hostedJson(target, path, args) {
1618
1704
  const metadata = await readDeployMetadata(root);
1619
- const url = await resolveDeployUrl(target, args, metadata);
1705
+ const { api, url } = await resolveHostedTarget(target, args, metadata);
1620
1706
  const inspectToken = inspectTokenForHostedTarget({ args, metadata, target, url });
1621
- const response = await fetch(`${url}${path}`, {
1622
- headers: inspectToken ? { Authorization: `Bearer ${inspectToken}` } : {}
1623
- });
1707
+ const headers = inspectToken ? { Authorization: `Bearer ${inspectToken}` } : {};
1708
+ let response;
1709
+ try {
1710
+ response = await fetch(`${url}${path}`, { headers });
1711
+ } catch (error) {
1712
+ if (!api || !isLocalApiUrl(api)) {
1713
+ throw error;
1714
+ }
1715
+
1716
+ response = await requestWithHostHeader(`${api}${path}`, {
1717
+ ...headers,
1718
+ Host: new URL(url).host
1719
+ });
1720
+ }
1624
1721
  return readResponseJson(response);
1625
1722
  }
1626
1723
 
@@ -1735,7 +1832,7 @@ async function dbCommand(args) {
1735
1832
  }
1736
1833
 
1737
1834
  function agentInstructionsTemplate() {
1738
- return `# Building with Lakebed.
1835
+ return `# Lakebed App Instructions
1739
1836
 
1740
1837
  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.
1741
1838
 
@@ -1751,7 +1848,7 @@ Your role is to build software within this capsule. Lakebed is the runtime, the
1751
1848
  - 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
1849
  - Styling must be done via raw CSS or Tailwind classes in the JSX.
1753
1850
  - 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.
1851
+ - There is no file based routing. Use the built-in client router from \`lakebed/client\` when you need pages.
1755
1852
  - All imports must be from Lakebed or from relative paths.
1756
1853
  - Do not use Node built-ins in app code.
1757
1854
  - Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
@@ -1788,6 +1885,10 @@ npx lakebed db dump --port 3000
1788
1885
  npx lakebed logs --port 3000
1789
1886
  \`\`\`
1790
1887
 
1888
+ ## External endpoints
1889
+
1890
+ Use \`endpoint({ method, path }, handler)\` from \`lakebed/server\` when the app needs to expose an HTTP route for webhooks or other non-Lakebed clients. Endpoint handlers receive request data including \`headers.get(name)\`, URL params, query params, and body helpers.
1891
+
1791
1892
  ## Additional resources
1792
1893
 
1793
1894
  - [Lakebed docs](https://docs.lakebed.dev/)
@@ -1812,7 +1913,7 @@ function todoTemplate(name) {
1812
1913
  return {
1813
1914
  "AGENTS.md": agentInstructions,
1814
1915
  "CLAUDE.md": agentInstructions,
1815
- "server/index.ts": `import { boolean, capsule, mutation, query, string, table } from "lakebed/server";
1916
+ "server/index.ts": `import { boolean, capsule, endpoint, mutation, query, string, table, text } from "lakebed/server";
1816
1917
  import { cleanTodoText } from "../shared/todo";
1817
1918
 
1818
1919
  export default capsule({
@@ -1844,10 +1945,15 @@ export default capsule({
1844
1945
 
1845
1946
  ctx.db.todos.insert({ text: cleanText, ownerId: ctx.auth.userId });
1846
1947
  })
1948
+ },
1949
+
1950
+ endpoints: {
1951
+ status: endpoint({ method: "GET", path: "/api/status" }, () => text("ok"))
1847
1952
  }
1848
1953
  });
1849
1954
  `,
1850
- "client/index.tsx": `import { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
1955
+ "client/index.tsx": `import { Link, Route, Router, Routes, SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
1956
+ import { useState } from "preact/hooks";
1851
1957
  import { cleanTodoText, type Todo } from "../shared/todo";
1852
1958
 
1853
1959
  function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
@@ -1874,12 +1980,9 @@ function AuthAvatar({ label, picture }: { label: string; picture?: string }) {
1874
1980
  );
1875
1981
  }
1876
1982
 
1877
- export function App() {
1878
- const auth = useAuth();
1983
+ function TodoPage() {
1879
1984
  const todos = useQuery<Todo[]>("todos");
1880
1985
  const addTodo = useMutation<[text: string], void>("addTodo");
1881
- const authLabel = auth.displayName;
1882
- const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
1883
1986
 
1884
1987
  async function onSubmit(event: SubmitEvent) {
1885
1988
  event.preventDefault();
@@ -1895,33 +1998,75 @@ export function App() {
1895
1998
  }
1896
1999
 
1897
2000
  return (
1898
- <main className="min-h-screen bg-black px-6 py-10 text-white">
1899
- <section className="mx-auto max-w-2xl">
1900
- <div className="mb-3 flex items-center justify-between gap-3">
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>
2001
+ <section>
2002
+ <h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
2003
+ <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
2004
+ <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" />
2005
+ <button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
2006
+ </form>
2007
+ <ul className="divide-y divide-neutral-800 border-y border-neutral-800">
2008
+ {todos.map((todo) => (
2009
+ <li className="py-3" key={todo.id}>{todo.text}</li>
2010
+ ))}
2011
+ </ul>
2012
+ </section>
2013
+ );
2014
+ }
2015
+
2016
+ function StatusPage() {
2017
+ const [status, setStatus] = useState("not checked");
2018
+
2019
+ async function checkStatus() {
2020
+ const response = await fetch("api/status");
2021
+ setStatus(response.ok ? await response.text() : "error " + response.status);
2022
+ }
2023
+
2024
+ return (
2025
+ <section>
2026
+ <h1 className="mb-4 text-4xl font-bold tracking-tight">Status</h1>
2027
+ <p className="mb-6 text-neutral-400">This route calls the server endpoint at /api/status.</p>
2028
+ <button className="border border-white px-4 py-2 font-medium" type="button" onClick={() => void checkStatus()}>
2029
+ Check endpoint
2030
+ </button>
2031
+ <p className="mt-4 font-mono text-sm text-neutral-400">endpoint: {status}</p>
2032
+ </section>
2033
+ );
2034
+ }
2035
+
2036
+ export function App() {
2037
+ const auth = useAuth();
2038
+ const authLabel = auth.displayName;
2039
+ const authStatus = auth.isLoading && auth.isGuest ? "checking session" : "signed in as " + authLabel;
2040
+
2041
+ return (
2042
+ <Router>
2043
+ <main className="min-h-screen bg-black px-6 py-10 text-white">
2044
+ <section className="mx-auto max-w-2xl">
2045
+ <div className="mb-3 flex items-center justify-between gap-3">
2046
+ <div className="flex min-w-0 items-center gap-2">
2047
+ {!auth.isLoading ? <AuthAvatar label={authLabel} picture={auth.picture} /> : null}
2048
+ <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
2049
+ </div>
2050
+ {!auth.isLoading && auth.isGuest ? (
2051
+ <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" />
2052
+ ) : !auth.isLoading ? (
2053
+ <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
2054
+ Sign out
2055
+ </button>
2056
+ ) : null}
1904
2057
  </div>
1905
- {!auth.isLoading && auth.isGuest ? (
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" />
1907
- ) : !auth.isLoading ? (
1908
- <button className="shrink-0 text-sm text-neutral-400 hover:text-white" type="button" onClick={() => signOut()}>
1909
- Sign out
1910
- </button>
1911
- ) : null}
1912
- </div>
1913
- <h1 className="mb-8 text-5xl font-bold tracking-tight">${title}</h1>
1914
- <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
1915
- <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" />
1916
- <button className="border border-white px-4 py-2 font-medium" type="submit">Add</button>
1917
- </form>
1918
- <ul className="divide-y divide-neutral-800 border-y border-neutral-800">
1919
- {todos.map((todo) => (
1920
- <li className="py-3" key={todo.id}>{todo.text}</li>
1921
- ))}
1922
- </ul>
1923
- </section>
1924
- </main>
2058
+ <nav className="mb-8 flex gap-4 text-sm text-neutral-400">
2059
+ <Link className="hover:text-white" to="/">Todos</Link>
2060
+ <Link className="hover:text-white" to="/status">Status</Link>
2061
+ </nav>
2062
+ <Routes>
2063
+ <Route path="/" element={<TodoPage />} />
2064
+ <Route path="/status" element={<StatusPage />} />
2065
+ <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>} />
2066
+ </Routes>
2067
+ </section>
2068
+ </main>
2069
+ </Router>
1925
2070
  );
1926
2071
  }
1927
2072
  `,
@@ -1948,6 +2093,17 @@ Run this Lakebed capsule:
1948
2093
  \`\`\`sh
1949
2094
  npx lakebed dev
1950
2095
  \`\`\`
2096
+
2097
+ The starter app includes two client routes:
2098
+
2099
+ - \`/\`: the todo list.
2100
+ - \`/status\`: a page that calls the \`GET /api/status\` endpoint.
2101
+
2102
+ You can also call the endpoint directly:
2103
+
2104
+ \`\`\`sh
2105
+ curl http://localhost:3000/api/status
2106
+ \`\`\`
1951
2107
  `
1952
2108
  };
1953
2109
  }
package/src/client.d.ts CHANGED
@@ -53,11 +53,49 @@ export type SignInWithGoogleProps = Omit<JSX.ButtonHTMLAttributes<HTMLButtonElem
53
53
  children?: ComponentChildren;
54
54
  };
55
55
 
56
+ export type LakebedLocation = {
57
+ pathname: string;
58
+ search: string;
59
+ hash: string;
60
+ href: string;
61
+ };
62
+
63
+ export type NavigateOptions = {
64
+ replace?: boolean;
65
+ };
66
+
67
+ export type RouterProps = {
68
+ children?: ComponentChildren;
69
+ };
70
+
71
+ export type RoutesProps = {
72
+ children?: ComponentChildren;
73
+ };
74
+
75
+ export type RouteProps = {
76
+ path: string;
77
+ element: ComponentChildren;
78
+ };
79
+
80
+ export type LinkProps = Omit<JSX.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
81
+ to: string;
82
+ replace?: boolean;
83
+ children?: ComponentChildren;
84
+ };
85
+
56
86
  export function useAuth(): Auth;
57
87
  export function signInWithGoogle(options?: SignInWithGoogleOptions): Promise<SignInWithGoogleResult>;
58
88
  export function signOut(): void;
59
89
  export function getIdentity(): Identity;
60
90
  export function decodeIdentityClaims(idToken?: string): IdentityClaims | null;
61
91
  export function SignInWithGoogle(props?: SignInWithGoogleProps): JSX.Element;
92
+ export function Router(props?: RouterProps): JSX.Element;
93
+ export function Routes(props?: RoutesProps): JSX.Element | null;
94
+ export function Route(props: RouteProps): JSX.Element | null;
95
+ export function Link(props: LinkProps): JSX.Element;
96
+ export function navigate(to: string, options?: NavigateOptions): void;
97
+ export function useLocation(): LakebedLocation;
98
+ export function useNavigate(): (to: string, options?: NavigateOptions) => void;
99
+ export function useParams<TParams = Record<string, string | undefined>>(): TParams;
62
100
  export function useQuery<T>(name: string): T;
63
101
  export function useMutation<TArgs extends unknown[], TResult>(name: string): (...args: TArgs) => Promise<TResult>;
package/src/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { h } from "preact";
2
- import { useEffect, useState } from "preact/hooks";
1
+ import { createContext, h, toChildArray } from "preact";
2
+ import { useContext, useEffect, useState } from "preact/hooks";
3
3
 
4
4
  const DEFAULT_SHOO_BASE_URL = "https://shoo.dev";
5
5
  const AUTH_STORAGE_KEY = "lakebed_identity";
@@ -23,6 +23,14 @@ let authInitialized = false;
23
23
  let authResumeStarted = false;
24
24
  let refreshRequested = false;
25
25
 
26
+ const RouterContext = createContext(null);
27
+ const RouteContext = createContext({ params: {} });
28
+
29
+ function normalizeBasePathValue(value) {
30
+ const clean = String(value ?? "").replace(/\/+$/g, "");
31
+ return clean === "/" ? "" : clean;
32
+ }
33
+
26
34
  function toGuestName(name) {
27
35
  return (
28
36
  String(name ?? "local")
@@ -144,7 +152,7 @@ function send(message) {
144
152
  }
145
153
 
146
154
  function basePath() {
147
- return window.__LAKEBED_BASE_PATH__ ?? "";
155
+ return normalizeBasePathValue(window.__LAKEBED_BASE_PATH__ ?? "");
148
156
  }
149
157
 
150
158
  function authConfig() {
@@ -159,6 +167,214 @@ function currentRoute() {
159
167
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
160
168
  }
161
169
 
170
+ function appPathnameFromBrowserPathname(pathname) {
171
+ const base = basePath();
172
+ if (!base) {
173
+ return pathname || "/";
174
+ }
175
+
176
+ if (pathname === base) {
177
+ return "/";
178
+ }
179
+
180
+ if (pathname.startsWith(`${base}/`)) {
181
+ return pathname.slice(base.length) || "/";
182
+ }
183
+
184
+ return pathname || "/";
185
+ }
186
+
187
+ function currentAppLocation() {
188
+ if (typeof window === "undefined") {
189
+ return { hash: "", href: "/", pathname: "/", search: "" };
190
+ }
191
+
192
+ const pathname = appPathnameFromBrowserPathname(window.location.pathname);
193
+ const search = window.location.search;
194
+ const hash = window.location.hash;
195
+ return {
196
+ hash,
197
+ href: `${pathname}${search}${hash}`,
198
+ pathname,
199
+ search
200
+ };
201
+ }
202
+
203
+ function isExternalHref(value) {
204
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//");
205
+ }
206
+
207
+ function browserHrefForAppHref(appHref) {
208
+ const url = new URL(appHref, "http://lakebed.local/");
209
+ const base = basePath();
210
+ const pathname = base ? `${base}${url.pathname === "/" ? "/" : url.pathname}` : url.pathname;
211
+ return `${pathname}${url.search}${url.hash}`;
212
+ }
213
+
214
+ function hrefForRoute(to) {
215
+ const value = String(to ?? "");
216
+ if (!value) {
217
+ return browserHrefForAppHref(currentAppLocation().href);
218
+ }
219
+
220
+ if (isExternalHref(value)) {
221
+ return value;
222
+ }
223
+
224
+ const current = currentAppLocation();
225
+ const resolved = new URL(value, `http://lakebed.local${current.href}`);
226
+ return browserHrefForAppHref(`${resolved.pathname}${resolved.search}${resolved.hash}`);
227
+ }
228
+
229
+ function emitLocationChange() {
230
+ window.dispatchEvent(new Event("lakebed:locationchange"));
231
+ }
232
+
233
+ export function navigate(to, options = {}) {
234
+ const href = hrefForRoute(to);
235
+ const parsed = new URL(href, window.location.href);
236
+
237
+ if (parsed.origin !== window.location.origin) {
238
+ window.location.assign(parsed.toString());
239
+ return;
240
+ }
241
+
242
+ const next = `${parsed.pathname}${parsed.search}${parsed.hash}`;
243
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
244
+ if (next === current) {
245
+ return;
246
+ }
247
+
248
+ if (options.replace) {
249
+ window.history.replaceState({}, "", next);
250
+ } else {
251
+ window.history.pushState({}, "", next);
252
+ }
253
+ emitLocationChange();
254
+ }
255
+
256
+ function useBrowserLocation() {
257
+ const [location, setLocation] = useState(currentAppLocation);
258
+
259
+ useEffect(() => {
260
+ function updateLocation() {
261
+ setLocation(currentAppLocation());
262
+ }
263
+
264
+ window.addEventListener("popstate", updateLocation);
265
+ window.addEventListener("lakebed:locationchange", updateLocation);
266
+ return () => {
267
+ window.removeEventListener("popstate", updateLocation);
268
+ window.removeEventListener("lakebed:locationchange", updateLocation);
269
+ };
270
+ }, []);
271
+
272
+ return location;
273
+ }
274
+
275
+ function normalizeMatchPath(path) {
276
+ const value = String(path ?? "/").trim();
277
+ if (value === "*" || value === "/*") {
278
+ return "*";
279
+ }
280
+
281
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
282
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/g, "") : "/";
283
+ }
284
+
285
+ function pathSegments(path) {
286
+ const normalized = normalizeMatchPath(path);
287
+ if (normalized === "*" || normalized === "/") {
288
+ return [];
289
+ }
290
+
291
+ return normalized.replace(/^\/+|\/+$/g, "").split("/");
292
+ }
293
+
294
+ function decodeRouteSegment(value) {
295
+ try {
296
+ return decodeURIComponent(value);
297
+ } catch {
298
+ return value;
299
+ }
300
+ }
301
+
302
+ function matchRoutePath(pattern, pathname) {
303
+ const normalizedPattern = normalizeMatchPath(pattern);
304
+ if (normalizedPattern === "*") {
305
+ return { params: {} };
306
+ }
307
+
308
+ const patternSegments = pathSegments(normalizedPattern);
309
+ const pathnameSegments = pathSegments(pathname);
310
+ const params = {};
311
+
312
+ for (let index = 0; index < patternSegments.length; index += 1) {
313
+ const patternSegment = patternSegments[index];
314
+ const pathnameSegment = pathnameSegments[index];
315
+
316
+ if (patternSegment === "*") {
317
+ params["*"] = pathnameSegments.slice(index).map(decodeRouteSegment).join("/");
318
+ return { params };
319
+ }
320
+
321
+ if (pathnameSegment === undefined) {
322
+ return null;
323
+ }
324
+
325
+ if (patternSegment.startsWith(":")) {
326
+ const name = patternSegment.slice(1);
327
+ if (!name) {
328
+ return null;
329
+ }
330
+ params[name] = decodeRouteSegment(pathnameSegment);
331
+ continue;
332
+ }
333
+
334
+ if (patternSegment !== pathnameSegment) {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ if (patternSegments.length !== pathnameSegments.length) {
340
+ return null;
341
+ }
342
+
343
+ return { params };
344
+ }
345
+
346
+ function routeChildren(children) {
347
+ const routes = [];
348
+ for (const child of toChildArray(children)) {
349
+ if (!child || typeof child !== "object") {
350
+ continue;
351
+ }
352
+
353
+ if (child.props?.path !== undefined) {
354
+ routes.push(child);
355
+ continue;
356
+ }
357
+
358
+ if (child.props?.children !== undefined) {
359
+ routes.push(...routeChildren(child.props.children));
360
+ }
361
+ }
362
+ return routes;
363
+ }
364
+
365
+ function shouldHandleLinkClick(event, target) {
366
+ return (
367
+ !event.defaultPrevented &&
368
+ event.button === 0 &&
369
+ !event.altKey &&
370
+ !event.ctrlKey &&
371
+ !event.metaKey &&
372
+ !event.shiftKey &&
373
+ (!target || target === "_self") &&
374
+ !event.currentTarget?.hasAttribute("download")
375
+ );
376
+ }
377
+
162
378
  function normalizeReturnTo(value) {
163
379
  if (!value) {
164
380
  return null;
@@ -746,6 +962,72 @@ export function SignInWithGoogle({
746
962
  );
747
963
  }
748
964
 
965
+ export function Router({ children } = {}) {
966
+ const location = useBrowserLocation();
967
+ return h(RouterContext.Provider, { value: { location, navigate } }, children);
968
+ }
969
+
970
+ export function Routes({ children } = {}) {
971
+ const location = useLocation();
972
+ const routes = routeChildren(children);
973
+ for (const route of routes) {
974
+ const match = matchRoutePath(route.props.path, location.pathname);
975
+ if (!match) {
976
+ continue;
977
+ }
978
+
979
+ return h(RouteContext.Provider, { value: match }, route.props.element ?? null);
980
+ }
981
+
982
+ return null;
983
+ }
984
+
985
+ export function Route() {
986
+ return null;
987
+ }
988
+
989
+ export function Link({ children, onClick, replace = false, target, to, ...props } = {}) {
990
+ const href = hrefForRoute(to);
991
+ return h(
992
+ "a",
993
+ {
994
+ ...props,
995
+ href,
996
+ onClick: (event) => {
997
+ onClick?.(event);
998
+ if (!shouldHandleLinkClick(event, target)) {
999
+ return;
1000
+ }
1001
+
1002
+ const parsed = new URL(href, window.location.href);
1003
+ if (parsed.origin !== window.location.origin) {
1004
+ return;
1005
+ }
1006
+
1007
+ event.preventDefault();
1008
+ navigate(to, { replace });
1009
+ },
1010
+ target
1011
+ },
1012
+ children
1013
+ );
1014
+ }
1015
+
1016
+ export function useLocation() {
1017
+ const context = useContext(RouterContext);
1018
+ const fallback = useBrowserLocation();
1019
+ return context?.location ?? fallback;
1020
+ }
1021
+
1022
+ export function useNavigate() {
1023
+ const context = useContext(RouterContext);
1024
+ return context?.navigate ?? navigate;
1025
+ }
1026
+
1027
+ export function useParams() {
1028
+ return useContext(RouteContext).params;
1029
+ }
1030
+
749
1031
  export function useQuery(name) {
750
1032
  const [value, setValue] = useState(queryValues.get(name) ?? []);
751
1033
 
@@ -0,0 +1,5 @@
1
+ import { startAnonymousServer } from "../anonymous-server.js";
2
+
3
+ await startAnonymousServer({
4
+ role: "all"
5
+ });
@@ -0,0 +1,7 @@
1
+ import { startAnonymousServer } from "../anonymous-server.js";
2
+
3
+ await startAnonymousServer({
4
+ dashboardRootUrl: process.env.LAKEBED_DASHBOARD_ROOT_URL ?? "https://dashboard.lakebed.dev",
5
+ publicRootUrl: process.env.PUBLIC_ROOT_URL ?? "https://api.lakebed.dev",
6
+ role: "api-dashboard"
7
+ });
@@ -0,0 +1,5 @@
1
+ import { startAnonymousServer } from "../anonymous-server.js";
2
+
3
+ await startAnonymousServer({
4
+ role: "runner"
5
+ });
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.19";
1
+ export const LAKEBED_VERSION = "0.0.21";