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