lakebed 0.0.20 → 0.0.22

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.20",
3
+ "version": "0.0.22",
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",
@@ -53,7 +56,7 @@
53
56
  "access": "public"
54
57
  },
55
58
  "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"
59
+ "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"
57
60
  },
58
61
  "dependencies": {
59
62
  "esbuild": "^0.27.1",
@@ -405,16 +405,89 @@ 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 isDashboardBrowserRoute(pathname) {
440
+ return (
441
+ pathname === "/" ||
442
+ pathname === "/deploys" ||
443
+ pathname === "/dashboard" ||
444
+ pathname === "/auth" ||
445
+ pathname.startsWith("/auth/") ||
446
+ pathname === "/admin" ||
447
+ pathname.startsWith("/admin/") ||
448
+ pathname.startsWith("/claim/")
449
+ );
450
+ }
451
+
452
+ function normalizedUrlHost(value) {
453
+ try {
454
+ return new URL(value).host.toLowerCase();
455
+ } catch {
456
+ return "";
457
+ }
458
+ }
459
+
460
+ function dashboardRedirectLocation({ dashboardRootUrl, requestUrl }) {
461
+ const pathname = requestUrl.pathname === "/" ? "/deploys" : requestUrl.pathname;
462
+ const target = new URL(pathname, dashboardRootUrl);
463
+ target.search = requestUrl.search;
464
+ return target.href;
465
+ }
466
+
467
+ function shouldRedirectToDashboardHost({ dashboardRootUrl, host, publicRootUrl, requestUrl }) {
468
+ if (!isDashboardBrowserRoute(requestUrl.pathname)) {
469
+ return false;
470
+ }
471
+
472
+ const publicHost = normalizedUrlHost(publicRootUrl);
473
+ const dashboardHost = normalizedUrlHost(dashboardRootUrl);
474
+ if (!publicHost || !dashboardHost || publicHost === dashboardHost) {
475
+ return false;
476
+ }
477
+
478
+ return String(host).toLowerCase() === publicHost;
479
+ }
480
+
481
+ function appUrlForSlug({ appBaseDomain, slug }) {
482
+ if (!appBaseDomain) {
483
+ throw new Error("LAKEBED_APP_BASE_DOMAIN is required to create hosted deploy URLs.");
484
+ }
485
+
486
+ return `https://${slug}.${appBaseDomain}`;
487
+ }
488
+
489
+ function claimUrlForDeploy({ dashboardRootUrl, deployId, token }) {
490
+ return `${dashboardRootUrl}/claim/${deployId}/${token}`;
418
491
  }
419
492
 
420
493
  function inspectUrls(url) {
@@ -426,11 +499,11 @@ function inspectUrls(url) {
426
499
  };
427
500
  }
428
501
 
429
- function responseForDeploy({ deploy, token }) {
502
+ function responseForDeploy({ dashboardRootUrl, deploy, token }) {
430
503
  return {
431
504
  claimed: Boolean(deploy.ownerId),
432
505
  claimedAt: deploy.claimedAt ?? undefined,
433
- claimUrl: token ? claimUrlForDeploy({ deployId: deploy.id, publicRootUrl: deploy.publicRootUrl, token }) : undefined,
506
+ claimUrl: token ? claimUrlForDeploy({ dashboardRootUrl: dashboardRootUrl ?? deploy.publicRootUrl, deployId: deploy.id, token }) : undefined,
434
507
  deployId: deploy.id,
435
508
  expiresAt: deploy.expiresAt,
436
509
  inspect: inspectUrls(deploy.url),
@@ -494,21 +567,6 @@ function isClientShellRequest(req, pathname) {
494
567
  return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
495
568
  }
496
569
 
497
- function parsePathDeploy(url) {
498
- const parts = url.pathname.split("/").filter(Boolean);
499
- if (parts[0] !== "d" || !parts[1]) {
500
- return null;
501
- }
502
-
503
- const prefix = `/d/${parts[1]}`;
504
- const appPath = url.pathname === prefix ? "/" : url.pathname.slice(prefix.length) || "/";
505
- return {
506
- appPath,
507
- basePath: prefix,
508
- slug: parts[1]
509
- };
510
- }
511
-
512
570
  function parseHostDeploy({ appBaseDomain, host, url }) {
513
571
  if (!appBaseDomain) {
514
572
  return null;
@@ -532,6 +590,10 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
532
590
  };
533
591
  }
534
592
 
593
+ function isDisabledPathDeployRoute(pathname) {
594
+ return pathname === "/d" || pathname.startsWith("/d/");
595
+ }
596
+
535
597
  function quotaLimitForBucket(bucket, deploy) {
536
598
  if (bucket === "mutations") {
537
599
  return deploy.limits.mutationsPerDay;
@@ -5716,7 +5778,7 @@ async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
5716
5778
  hostname: domain.hostname,
5717
5779
  deployId: domain.deployId
5718
5780
  }
5719
- : parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
5781
+ : parseHostDeploy({ appBaseDomain, host, url });
5720
5782
  if (!route) {
5721
5783
  return null;
5722
5784
  }
@@ -5934,17 +5996,23 @@ async function serveInspect({ adminPassword, artifact, currentDeveloper, deploy,
5934
5996
  export async function startAnonymousServer({
5935
5997
  adminPassword = adminPasswordFromEnv(),
5936
5998
  appBaseDomain = process.env.LAKEBED_APP_BASE_DOMAIN ?? "",
5999
+ dashboardRootUrl = process.env.LAKEBED_DASHBOARD_ROOT_URL,
5937
6000
  developerSessionSecret = process.env.LAKEBED_SESSION_SECRET ?? "",
5938
6001
  githubOAuth = githubOAuthFromEnv(),
5939
6002
  port = Number(process.env.PORT ?? 8787),
5940
6003
  publicRootUrl,
5941
6004
  quiet = false,
6005
+ role = process.env.LAKEBED_SERVER_ROLE ?? "all",
5942
6006
  shooBaseUrl = shooBaseUrlFromEnv(),
5943
6007
  sourceRuntime,
5944
6008
  store
5945
6009
  } = {}) {
6010
+ const resolvedRole = normalizeServerRole(role);
6011
+ const servesApiDashboard = roleServesApiDashboard(resolvedRole);
6012
+ const servesRunner = roleServesRunner(resolvedRole);
5946
6013
  const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
5947
6014
  const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
6015
+ const resolvedDashboardRootUrl = normalizePublicRootUrl(dashboardRootUrl ?? resolvedPublicRootUrl, port);
5948
6016
  const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
5949
6017
  const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
5950
6018
  const cleanupPolicy = cleanupPolicyFromEnv();
@@ -5952,7 +6020,7 @@ export async function startAnonymousServer({
5952
6020
  const resolvedDeveloperSessionSecret =
5953
6021
  developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
5954
6022
  const resolvedStore = store ?? (await createAnonymousStoreFromEnv());
5955
- const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
6023
+ const resolvedSourceRuntime = sourceRuntime === undefined && servesRunner ? createSourceRuntimeFromEnv() : sourceRuntime;
5956
6024
  await resolvedStore.initialize();
5957
6025
  const subscriptions = new Map();
5958
6026
  let cleanupInterval = null;
@@ -6191,7 +6259,20 @@ export async function startAnonymousServer({
6191
6259
  return;
6192
6260
  }
6193
6261
 
6194
- if (req.method === "GET" && (requestUrl.pathname === "/deploys" || requestUrl.pathname === "/dashboard")) {
6262
+ if (
6263
+ servesApiDashboard &&
6264
+ shouldRedirectToDashboardHost({
6265
+ dashboardRootUrl: resolvedDashboardRootUrl,
6266
+ host,
6267
+ publicRootUrl: resolvedPublicRootUrl,
6268
+ requestUrl
6269
+ })
6270
+ ) {
6271
+ redirect(res, dashboardRedirectLocation({ dashboardRootUrl: resolvedDashboardRootUrl, requestUrl }));
6272
+ return;
6273
+ }
6274
+
6275
+ if (servesApiDashboard && req.method === "GET" && (requestUrl.pathname === "/deploys" || requestUrl.pathname === "/dashboard")) {
6195
6276
  const authConfigured = developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret);
6196
6277
  const user = authConfigured ? currentDeveloper(req) : null;
6197
6278
  sendText(
@@ -6207,7 +6288,7 @@ export async function startAnonymousServer({
6207
6288
  return;
6208
6289
  }
6209
6290
 
6210
- if (req.method === "GET" && requestUrl.pathname === "/v1/me") {
6291
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/v1/me") {
6211
6292
  const user = currentDeveloper(req);
6212
6293
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6213
6294
  sendJson(res, 401, { error: "Developer authentication required." });
@@ -6218,7 +6299,7 @@ export async function startAnonymousServer({
6218
6299
  return;
6219
6300
  }
6220
6301
 
6221
- if (req.method === "GET" && requestUrl.pathname === "/v1/me/deploys") {
6302
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/v1/me/deploys") {
6222
6303
  const user = currentDeveloper(req);
6223
6304
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
6224
6305
  sendJson(res, 401, { error: "Developer authentication required." });
@@ -6230,7 +6311,7 @@ export async function startAnonymousServer({
6230
6311
  }
6231
6312
 
6232
6313
  const developerTerminateMatch =
6233
- req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6314
+ servesApiDashboard && req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
6234
6315
  if (developerTerminateMatch) {
6235
6316
  const user = currentDeveloper(req);
6236
6317
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
@@ -6266,7 +6347,7 @@ export async function startAnonymousServer({
6266
6347
  return;
6267
6348
  }
6268
6349
 
6269
- if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
6350
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/github") {
6270
6351
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6271
6352
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
6272
6353
  return;
@@ -6284,7 +6365,7 @@ export async function startAnonymousServer({
6284
6365
  );
6285
6366
  const authorizeUrl = new URL(resolvedGithubOAuth.authorizeUrl);
6286
6367
  authorizeUrl.searchParams.set("client_id", resolvedGithubOAuth.clientId);
6287
- authorizeUrl.searchParams.set("redirect_uri", githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl));
6368
+ authorizeUrl.searchParams.set("redirect_uri", githubRedirectUri(resolvedGithubOAuth, resolvedDashboardRootUrl));
6288
6369
  authorizeUrl.searchParams.set("scope", "read:user");
6289
6370
  authorizeUrl.searchParams.set("state", state);
6290
6371
  redirect(res, authorizeUrl.href, {
@@ -6293,7 +6374,7 @@ export async function startAnonymousServer({
6293
6374
  return;
6294
6375
  }
6295
6376
 
6296
- if (req.method === "GET" && requestUrl.pathname === "/auth/github/callback") {
6377
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/github/callback") {
6297
6378
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6298
6379
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
6299
6380
  return;
@@ -6339,7 +6420,7 @@ export async function startAnonymousServer({
6339
6420
  const user = await githubUserFromCode({
6340
6421
  code,
6341
6422
  githubOAuth: resolvedGithubOAuth,
6342
- redirectUri: githubRedirectUri(resolvedGithubOAuth, resolvedPublicRootUrl)
6423
+ redirectUri: githubRedirectUri(resolvedGithubOAuth, resolvedDashboardRootUrl)
6343
6424
  });
6344
6425
  redirect(res, normalizeReturnTo(statePayload.returnTo), {
6345
6426
  "Set-Cookie": [developerCookie(user, resolvedDeveloperSessionSecret, secure), clearOauthStateCookie(secure)]
@@ -6347,14 +6428,14 @@ export async function startAnonymousServer({
6347
6428
  return;
6348
6429
  }
6349
6430
 
6350
- if (req.method === "GET" && requestUrl.pathname === "/auth/logout") {
6431
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname === "/auth/logout") {
6351
6432
  redirect(res, "/deploys", {
6352
6433
  "Set-Cookie": clearDeveloperCookie(isSecureRequest(req))
6353
6434
  });
6354
6435
  return;
6355
6436
  }
6356
6437
 
6357
- const claimMatch = req.method === "GET" ? requestUrl.pathname.match(/^\/claim\/([^/]+)\/([^/]+)$/) : null;
6438
+ const claimMatch = servesApiDashboard && req.method === "GET" ? requestUrl.pathname.match(/^\/claim\/([^/]+)\/([^/]+)$/) : null;
6358
6439
  if (claimMatch) {
6359
6440
  if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
6360
6441
  sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
@@ -6392,6 +6473,7 @@ export async function startAnonymousServer({
6392
6473
  }
6393
6474
 
6394
6475
  if (
6476
+ servesApiDashboard &&
6395
6477
  req.method === "GET" &&
6396
6478
  (requestUrl.pathname === "/admin" ||
6397
6479
  requestUrl.pathname === "/admin/" ||
@@ -6403,7 +6485,7 @@ export async function startAnonymousServer({
6403
6485
  return;
6404
6486
  }
6405
6487
 
6406
- if (requestUrl.pathname === "/admin/api/login" && req.method === "POST") {
6488
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/login" && req.method === "POST") {
6407
6489
  if (!isAdminConfigured(adminPassword)) {
6408
6490
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
6409
6491
  return;
@@ -6419,12 +6501,12 @@ export async function startAnonymousServer({
6419
6501
  return;
6420
6502
  }
6421
6503
 
6422
- if (requestUrl.pathname === "/admin/api/logout" && req.method === "POST") {
6504
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/logout" && req.method === "POST") {
6423
6505
  sendJson(res, 200, { ok: true }, { "Set-Cookie": adminCookie("", 0, isSecureRequest(req)) });
6424
6506
  return;
6425
6507
  }
6426
6508
 
6427
- if (requestUrl.pathname === "/admin/api/summary" && req.method === "GET") {
6509
+ if (servesApiDashboard && requestUrl.pathname === "/admin/api/summary" && req.method === "GET") {
6428
6510
  if (!isAdminConfigured(adminPassword)) {
6429
6511
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
6430
6512
  return;
@@ -6440,7 +6522,7 @@ export async function startAnonymousServer({
6440
6522
  }
6441
6523
 
6442
6524
  const adminUserDetailMatch =
6443
- req.method === "GET" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)$/) : null;
6525
+ servesApiDashboard && req.method === "GET" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)$/) : null;
6444
6526
  if (adminUserDetailMatch) {
6445
6527
  if (!isAdminConfigured(adminPassword)) {
6446
6528
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
@@ -6463,7 +6545,7 @@ export async function startAnonymousServer({
6463
6545
  }
6464
6546
 
6465
6547
  const userLimitsMatch =
6466
- req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
6548
+ servesApiDashboard && req.method === "PUT" ? requestUrl.pathname.match(/^\/admin\/api\/users\/([^/]+)\/limits$/) : null;
6467
6549
  if (userLimitsMatch) {
6468
6550
  if (!isAdminConfigured(adminPassword)) {
6469
6551
  sendJson(res, 503, { error: "Lakebed admin is not configured. Set LAKEBED_ADMIN_PASSWORD." });
@@ -6499,7 +6581,7 @@ export async function startAnonymousServer({
6499
6581
  }
6500
6582
 
6501
6583
  const terminateMatch =
6502
- req.method === "POST"
6584
+ servesApiDashboard && req.method === "POST"
6503
6585
  ? requestUrl.pathname.match(/^\/admin\/api\/deploys\/([^/]+)\/terminate$/)
6504
6586
  : null;
6505
6587
  if (terminateMatch) {
@@ -6533,7 +6615,7 @@ export async function startAnonymousServer({
6533
6615
  }
6534
6616
 
6535
6617
  const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
6536
- if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
6618
+ if (servesApiDashboard && deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
6537
6619
  const deployId = decodeURIComponent(deployDomainsMatch[1]);
6538
6620
  const currentDeploy = await resolvedStore.getDeployById(deployId);
6539
6621
  if (!currentDeploy) {
@@ -6616,7 +6698,12 @@ export async function startAnonymousServer({
6616
6698
  return;
6617
6699
  }
6618
6700
 
6619
- if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6701
+ if (servesApiDashboard && req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
6702
+ if (!resolvedAppBaseDomain) {
6703
+ sendJson(res, 503, { error: "LAKEBED_APP_BASE_DOMAIN is required to create hosted deploys." });
6704
+ return;
6705
+ }
6706
+
6620
6707
  await enforceAnonymousDeployCreation(req);
6621
6708
  const body = await readJsonBody(req);
6622
6709
  const payload = validateAnonymousDeployPayload(body);
@@ -6642,11 +6729,16 @@ export async function startAnonymousServer({
6642
6729
  serverEnv: payload.serverEnv
6643
6730
  });
6644
6731
  await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
6645
- sendJson(res, 201, responseForDeploy({ deploy, token }));
6732
+ sendJson(res, 201, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy, token }));
6646
6733
  return;
6647
6734
  }
6648
6735
 
6649
- if ((req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
6736
+ if (servesApiDashboard && (req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
6737
+ if (!resolvedAppBaseDomain) {
6738
+ sendJson(res, 503, { error: "LAKEBED_APP_BASE_DOMAIN is required to update hosted deploys." });
6739
+ return;
6740
+ }
6741
+
6650
6742
  const deployId = requestUrl.pathname.slice("/v1/deploys/".length);
6651
6743
  const currentDeploy = await resolvedStore.getDeployById(deployId);
6652
6744
  if (!currentDeploy) {
@@ -6692,23 +6784,43 @@ export async function startAnonymousServer({
6692
6784
  await refreshDeploySubscriptions(deploy);
6693
6785
  refreshDeployClients(deploy);
6694
6786
  await publishDeploy(deploy.id);
6695
- sendJson(res, 200, responseForDeploy({ deploy }));
6787
+ sendJson(res, 200, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy }));
6696
6788
  return;
6697
6789
  }
6698
6790
 
6699
- if (req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
6791
+ if (servesApiDashboard && req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
6700
6792
  const id = requestUrl.pathname.slice("/v1/deploys/".length);
6701
6793
  const deploy = (await resolvedStore.getDeployById(id)) ?? (await resolvedStore.getDeployBySlug(id));
6702
6794
  if (!deploy) {
6703
6795
  sendJson(res, 404, { error: "Unknown deploy." });
6704
6796
  return;
6705
6797
  }
6706
- sendJson(res, 200, responseForDeploy({ deploy }));
6798
+ sendJson(res, 200, responseForDeploy({ dashboardRootUrl: resolvedDashboardRootUrl, deploy }));
6799
+ return;
6800
+ }
6801
+
6802
+ if (isDisabledPathDeployRoute(requestUrl.pathname)) {
6803
+ sendText(res, 404, "Path-based deploy URLs are no longer supported.\n", { "Content-Type": "text/plain; charset=utf-8" });
6804
+ return;
6805
+ }
6806
+
6807
+ if (!servesRunner) {
6808
+ if (req.method === "GET" && requestUrl.pathname === "/") {
6809
+ redirect(res, "/deploys");
6810
+ return;
6811
+ }
6812
+
6813
+ sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6707
6814
  return;
6708
6815
  }
6709
6816
 
6710
6817
  const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
6711
6818
  if (!loaded) {
6819
+ if (!servesApiDashboard && isApiDashboardRoute(requestUrl.pathname)) {
6820
+ sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
6821
+ return;
6822
+ }
6823
+
6712
6824
  sendText(res, 200, "Lakebed anonymous deploy runner\n", { "Content-Type": "text/plain; charset=utf-8" });
6713
6825
  return;
6714
6826
  }
@@ -6919,6 +7031,11 @@ export async function startAnonymousServer({
6919
7031
  });
6920
7032
 
6921
7033
  server.on("upgrade", async (req, socket, head) => {
7034
+ if (!servesRunner) {
7035
+ socket.destroy();
7036
+ return;
7037
+ }
7038
+
6922
7039
  const host = req.headers.host ?? "localhost";
6923
7040
  const requestUrl = new URL(req.url ?? "/", `http://${host}`);
6924
7041
  const loaded = await loadDeployByRoute({ appBaseDomain: resolvedAppBaseDomain, host, store: resolvedStore, url: requestUrl });
@@ -6971,8 +7088,10 @@ export async function startAnonymousServer({
6971
7088
 
6972
7089
  return {
6973
7090
  appBaseDomain: resolvedAppBaseDomain,
7091
+ dashboardRootUrl: resolvedDashboardRootUrl,
6974
7092
  port,
6975
7093
  publicRootUrl: resolvedPublicRootUrl,
7094
+ role: resolvedRole,
6976
7095
  store: resolvedStore,
6977
7096
  url: resolvedPublicRootUrl,
6978
7097
  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,8 @@ 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";
34
+ const legacyDeployApiUrl = "https://api.lakebed.app";
33
35
  const execFileAsync = promisify(execFile);
34
36
  const endpointBodyMaxBytes = 2 * 1024 * 1024;
35
37
 
@@ -44,7 +46,7 @@ Usage:
44
46
  npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
45
47
  npx lakebed claim [capsule-dir] [--api <url>] [--json]
46
48
  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>]
49
+ npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--dashboard-root-url <url>] [--app-base-domain <domain>] [--role all|api-dashboard|runner]
48
50
  npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
49
51
  npx lakebed run-many [capsule-dir] [--count 20] [--base-port 4000]
50
52
  npx lakebed auth as <name>
@@ -69,10 +71,12 @@ const optionsWithValues = new Set([
69
71
  "--app-base-domain",
70
72
  "--base-port",
71
73
  "--count",
74
+ "--dashboard-root-url",
72
75
  "--inspect-token",
73
76
  "--out",
74
77
  "--port",
75
78
  "--public-root-url",
79
+ "--role",
76
80
  "--target",
77
81
  "--template"
78
82
  ]);
@@ -1190,12 +1194,12 @@ function claimTokenFromDeployResponse(deployed) {
1190
1194
  return null;
1191
1195
  }
1192
1196
 
1193
- function claimUrlFromDeployMetadata(metadata) {
1197
+ function claimUrlFromDeployMetadata(metadata, api = metadata?.api) {
1194
1198
  if (!metadata?.api || !metadata?.deployId || !metadata?.claimToken) {
1195
1199
  return null;
1196
1200
  }
1197
1201
 
1198
- return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
1202
+ return `${normalizeHostedUrl(api)}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
1199
1203
  }
1200
1204
 
1201
1205
  function claimCommandText({ api, capsuleArg }) {
@@ -1327,6 +1331,15 @@ function normalizeHostedUrl(value) {
1327
1331
  }
1328
1332
  }
1329
1333
 
1334
+ function canonicalDeployApiUrl(value) {
1335
+ const normalized = normalizeHostedUrl(value);
1336
+ return normalized === legacyDeployApiUrl ? defaultDeployApiUrl : normalized;
1337
+ }
1338
+
1339
+ function deployApiUrlsMatch(left, right) {
1340
+ return canonicalDeployApiUrl(left) === canonicalDeployApiUrl(right);
1341
+ }
1342
+
1330
1343
  async function readResponseJson(response) {
1331
1344
  const body = await response.text();
1332
1345
  if (!response.ok) {
@@ -1351,7 +1364,7 @@ async function deployCommand(args) {
1351
1364
  const inspectPolicy = hasFlag(args, "--public-inspect") ? "public" : undefined;
1352
1365
  const metadata = await readDeployMetadata(capsuleDir);
1353
1366
  const canUpdate =
1354
- metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
1367
+ deployApiUrlsMatch(metadata?.api, api) && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
1355
1368
  let currentDeploy = null;
1356
1369
  if (canUpdate) {
1357
1370
  const currentResponse = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`);
@@ -1495,11 +1508,11 @@ async function claimCommand(args) {
1495
1508
  throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
1496
1509
  }
1497
1510
 
1498
- if (metadata.api !== api) {
1511
+ if (!deployApiUrlsMatch(metadata.api, api)) {
1499
1512
  throw new Error(`Saved deploy metadata is for ${metadata.api}, but this command is using ${api}. Pass --api ${metadata.api} to claim it.`);
1500
1513
  }
1501
1514
 
1502
- const claimUrl = claimUrlFromDeployMetadata(metadata);
1515
+ const claimUrl = claimUrlFromDeployMetadata(metadata, api);
1503
1516
  if (!claimUrl) {
1504
1517
  throw new Error("This project does not have a saved claim token. Redeploy to create a new claim URL.");
1505
1518
  }
@@ -1558,7 +1571,7 @@ async function domainsCommand(args) {
1558
1571
  if (!metadata) {
1559
1572
  throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run npx lakebed deploy from this project first.`);
1560
1573
  }
1561
- if (metadata.api !== api) {
1574
+ if (!deployApiUrlsMatch(metadata.api, api)) {
1562
1575
  throw new Error(`Saved deploy metadata is for ${metadata.api}, but this command is using ${api}. Pass --api ${metadata.api} to use it.`);
1563
1576
  }
1564
1577
  if (!metadata.deployId || !metadata.claimToken) {
@@ -1590,15 +1603,17 @@ async function anonymousServerCommand(args) {
1590
1603
  const port = readNumberArg(args, "--port", Number(process.env.PORT ?? 8787));
1591
1604
  await startAnonymousServer({
1592
1605
  appBaseDomain: readArg(args, "--app-base-domain", process.env.LAKEBED_APP_BASE_DOMAIN ?? ""),
1606
+ dashboardRootUrl: readArg(args, "--dashboard-root-url", process.env.LAKEBED_DASHBOARD_ROOT_URL),
1593
1607
  port,
1594
- publicRootUrl: readArg(args, "--public-root-url", process.env.PUBLIC_ROOT_URL ?? `http://localhost:${port}`)
1608
+ publicRootUrl: readArg(args, "--public-root-url", process.env.PUBLIC_ROOT_URL ?? `http://localhost:${port}`),
1609
+ role: readArg(args, "--role", process.env.LAKEBED_SERVER_ROLE ?? "all")
1595
1610
  });
1596
1611
  await new Promise(() => {});
1597
1612
  }
1598
1613
 
1599
1614
  function deployLookupApiUrl(target, args, metadata) {
1600
1615
  if (!hasExplicitOption(args, "--api") && metadata?.api && metadata.deployId === target) {
1601
- return String(metadata.api).replace(/\/+$/g, "");
1616
+ return canonicalDeployApiUrl(metadata.api);
1602
1617
  }
1603
1618
 
1604
1619
  return deployApiUrl(args);
@@ -1620,6 +1635,61 @@ async function resolveDeployUrl(target, args, metadata) {
1620
1635
  }
1621
1636
  }
1622
1637
 
1638
+ async function resolveHostedTarget(target, args, metadata) {
1639
+ if (!target) {
1640
+ throw new Error("Expected a deploy ID or URL.");
1641
+ }
1642
+
1643
+ try {
1644
+ const url = new URL(target);
1645
+ return { api: "", url: url.href.replace(/\/+$/g, "") };
1646
+ } catch {
1647
+ const api = deployLookupApiUrl(target, args, metadata);
1648
+ const response = await fetch(`${api}/v1/deploys/${encodeURIComponent(target)}`);
1649
+ const deploy = await readResponseJson(response);
1650
+ return { api, url: deploy.url.replace(/\/+$/g, "") };
1651
+ }
1652
+ }
1653
+
1654
+ function isLocalApiUrl(value) {
1655
+ try {
1656
+ const { hostname } = new URL(value);
1657
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
1658
+ } catch {
1659
+ return false;
1660
+ }
1661
+ }
1662
+
1663
+ async function requestWithHostHeader(url, headers) {
1664
+ const requestUrl = new URL(url);
1665
+ const transport = requestUrl.protocol === "https:" ? httpsRequest : httpRequest;
1666
+ return new Promise((resolveRequest, rejectRequest) => {
1667
+ const req = transport(
1668
+ {
1669
+ headers,
1670
+ hostname: requestUrl.hostname,
1671
+ method: "GET",
1672
+ path: `${requestUrl.pathname}${requestUrl.search}`,
1673
+ port: requestUrl.port
1674
+ },
1675
+ (res) => {
1676
+ const chunks = [];
1677
+ res.on("data", (chunk) => chunks.push(chunk));
1678
+ res.on("end", () => {
1679
+ const body = Buffer.concat(chunks).toString("utf8");
1680
+ resolveRequest({
1681
+ ok: (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300,
1682
+ status: res.statusCode ?? 500,
1683
+ text: async () => body
1684
+ });
1685
+ });
1686
+ }
1687
+ );
1688
+ req.on("error", rejectRequest);
1689
+ req.end();
1690
+ });
1691
+ }
1692
+
1623
1693
  function inspectTokenForHostedTarget({ args, metadata, target, url }) {
1624
1694
  const explicitToken = readArg(args, "--inspect-token", process.env.LAKEBED_INSPECT_TOKEN ?? "");
1625
1695
  if (explicitToken) {
@@ -1642,11 +1712,22 @@ function inspectTokenForHostedTarget({ args, metadata, target, url }) {
1642
1712
 
1643
1713
  async function hostedJson(target, path, args) {
1644
1714
  const metadata = await readDeployMetadata(root);
1645
- const url = await resolveDeployUrl(target, args, metadata);
1715
+ const { api, url } = await resolveHostedTarget(target, args, metadata);
1646
1716
  const inspectToken = inspectTokenForHostedTarget({ args, metadata, target, url });
1647
- const response = await fetch(`${url}${path}`, {
1648
- headers: inspectToken ? { Authorization: `Bearer ${inspectToken}` } : {}
1649
- });
1717
+ const headers = inspectToken ? { Authorization: `Bearer ${inspectToken}` } : {};
1718
+ let response;
1719
+ try {
1720
+ response = await fetch(`${url}${path}`, { headers });
1721
+ } catch (error) {
1722
+ if (!api || !isLocalApiUrl(api)) {
1723
+ throw error;
1724
+ }
1725
+
1726
+ response = await requestWithHostHeader(`${api}${path}`, {
1727
+ ...headers,
1728
+ Host: new URL(url).host
1729
+ });
1730
+ }
1650
1731
  return readResponseJson(response);
1651
1732
  }
1652
1733
 
@@ -1761,7 +1842,7 @@ async function dbCommand(args) {
1761
1842
  }
1762
1843
 
1763
1844
  function agentInstructionsTemplate() {
1764
- return `# Building with Lakebed.
1845
+ return `# Lakebed App Instructions
1765
1846
 
1766
1847
  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
1848
 
@@ -1814,6 +1895,10 @@ npx lakebed db dump --port 3000
1814
1895
  npx lakebed logs --port 3000
1815
1896
  \`\`\`
1816
1897
 
1898
+ ## External endpoints
1899
+
1900
+ 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.
1901
+
1817
1902
  ## Additional resources
1818
1903
 
1819
1904
  - [Lakebed docs](https://docs.lakebed.dev/)
@@ -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.20";
1
+ export const LAKEBED_VERSION = "0.0.22";