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 +14 -5
- package/package.json +8 -5
- package/src/anonymous-server.js +137 -45
- package/src/cli.js +198 -42
- package/src/client.d.ts +38 -0
- package/src/client.js +285 -3
- package/src/services/all-in-one.js +5 -0
- package/src/services/api-dashboard.js +7 -0
- package/src/services/capsule-runner.js +5 -0
- package/src/version.js +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/anonymous-server.js
CHANGED
|
@@ -405,16 +405,47 @@ function normalizeLakebedSubdomainInput(value, appBaseDomain) {
|
|
|
405
405
|
};
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
function
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
+
throw new Error(`Unsupported LAKEBED_SERVER_ROLE: ${role}. Expected all, api-dashboard, or runner.`);
|
|
414
415
|
}
|
|
415
416
|
|
|
416
|
-
function
|
|
417
|
-
return
|
|
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({
|
|
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
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
508
|
+
function wantsHtml(req) {
|
|
509
|
+
const accept = String(req.headers.accept ?? "");
|
|
510
|
+
return !accept || accept.includes("text/html");
|
|
511
|
+
}
|
|
482
512
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 })
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
|
1705
|
+
const { api, url } = await resolveHostedTarget(target, args, metadata);
|
|
1620
1706
|
const inspectToken = inspectTokenForHostedTarget({ args, metadata, target, url });
|
|
1621
|
-
const
|
|
1622
|
-
|
|
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 `#
|
|
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.
|
|
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
|
-
|
|
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
|
-
<
|
|
1899
|
-
<
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
1906
|
-
<
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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,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
|
+
});
|
package/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const LAKEBED_VERSION = "0.0.
|
|
1
|
+
export const LAKEBED_VERSION = "0.0.21";
|