lakebed 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,7 @@ lakebed new <name> [--template todo]
93
93
  lakebed dev <capsule-dir> [--port 3000]
94
94
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
95
95
  lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
96
+ lakebed claim [capsule-dir] [--api <url>] [--json]
96
97
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
97
98
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
98
99
  lakebed db list [deploy-id-or-url] [--port 3000]
@@ -143,7 +144,7 @@ LAKEBED_GITHUB_CLIENT_SECRET=...
143
144
  LAKEBED_SESSION_SECRET=...
144
145
  ```
145
146
 
146
- Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys. The current hosted runner still uses the anonymous interpreter artifact, so outbound `fetch` remains blocked until Lakebed has a claimed runtime target that can safely execute server code.
147
+ Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys. Anonymous deploys cannot use outbound `fetch`; after a deploy is claimed, `lakebed deploy` can update it with a source-backed server artifact that supports async handlers and server-side fetch. If the first deploy already needs server-side `fetch`, `lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the claim URL. Open that URL, then run `lakebed deploy` again to publish the real source-backed app.
147
148
 
148
149
  ## Admin Dashboard
149
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  "src/runtime.js",
30
30
  "src/server.d.ts",
31
31
  "src/server.js",
32
- "src/source-store.js"
32
+ "src/source-store.js",
33
+ "src/version.js"
33
34
  ],
34
35
  "exports": {
35
36
  "./package.json": "./package.json",
@@ -48,6 +49,9 @@
48
49
  "publishConfig": {
49
50
  "access": "public"
50
51
  },
52
+ "scripts": {
53
+ "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/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
54
+ },
51
55
  "dependencies": {
52
56
  "esbuild": "^0.27.1",
53
57
  "pg": "^8.16.3",
@@ -56,8 +60,5 @@
56
60
  },
57
61
  "devDependencies": {
58
62
  "@types/ws": "^8.18.1"
59
- },
60
- "scripts": {
61
- "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/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js"
62
63
  }
63
- }
64
+ }
@@ -2794,7 +2794,7 @@ export async function startAnonymousServer({
2794
2794
  }
2795
2795
 
2796
2796
  const body = await readJsonBody(req);
2797
- const payload = validateAnonymousDeployPayload(body);
2797
+ const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
2798
2798
  const deploy = await resolvedStore.updateDeploy({
2799
2799
  appBaseDomain: resolvedAppBaseDomain,
2800
2800
  artifact: payload.artifact,
package/src/anonymous.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { LAKEBED_VERSION } from "./version.js";
3
4
 
4
5
  export const ANONYMOUS_ARTIFACT_FORMAT = "lakebed.capsule.artifact.v1";
5
6
  export const ANONYMOUS_ARTIFACT_MEDIA_TYPE = "application/vnd.lakebed.artifact+json";
7
+ export { LAKEBED_VERSION };
6
8
 
7
9
  export const DEFAULT_ANONYMOUS_LIMITS = {
8
10
  artifactBytes: 1024 * 1024,
@@ -17,6 +19,7 @@ export const DEFAULT_ANONYMOUS_LIMITS = {
17
19
 
18
20
  const expressionOps = new Set(["arg", "auth", "call", "row"]);
19
21
  const authFields = new Set(["displayName", "email", "emailVerified", "isAuthenticated", "isGuest", "name", "picture", "provider", "userId"]);
22
+ const sourceModuleCache = new Map();
20
23
 
21
24
  export class AnonymousCompilerError extends Error {
22
25
  constructor(diagnostics) {
@@ -493,7 +496,7 @@ function compileServerToIr(app, schema) {
493
496
  return { diagnostics, mutations, queries };
494
497
  }
495
498
 
496
- export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.3" }) {
499
+ export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = LAKEBED_VERSION }) {
497
500
  const sourceFiles = await readSourceFiles(sourceStore);
498
501
  const diagnostics = forbiddenSourceDiagnostics(sourceFiles);
499
502
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
@@ -555,6 +558,77 @@ export async function createAnonymousArtifact({ app, clientOut, sourceStore, ver
555
558
  };
556
559
  }
557
560
 
561
+ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceStore, version = LAKEBED_VERSION }) {
562
+ if (!serverOut) {
563
+ throw new AnonymousCompilerError([diagnostic("server/index.ts", "Claimed deploys require a bundled server module.")]);
564
+ }
565
+
566
+ const sourceFiles = await readSourceFiles(sourceStore);
567
+ const diagnostics = forbiddenSourceDiagnostics(sourceFiles).filter(
568
+ (entry) =>
569
+ entry.message !== "Outbound fetch is disabled for anonymous deploys." &&
570
+ entry.message !== "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
571
+ );
572
+ const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
573
+ diagnostics.push(...schemaDiagnostics);
574
+ if (diagnostics.length > 0) {
575
+ throw new AnonymousCompilerError(diagnostics);
576
+ }
577
+
578
+ const clientBundle = await readFile(clientOut);
579
+ const serverBundle = await readFile(serverOut);
580
+ const clientBundleBase64 = clientBundle.toString("base64");
581
+ const clientBundleHash = sha256(clientBundle);
582
+ const serverBundleBase64 = serverBundle.toString("base64");
583
+ const serverBundleHash = sha256(serverBundle);
584
+ const sourceManifest = sourceFiles.map(({ bytes, hash, path }) => ({ bytes, hash, path }));
585
+ const sourceSnapshotHash = sha256(stableStringify(sourceManifest));
586
+ const artifact = {
587
+ name: app.name ?? "Lakebed Capsule",
588
+ client: {
589
+ bundleHash: clientBundleHash,
590
+ bytes: clientBundle.byteLength,
591
+ entry: "/client.js"
592
+ },
593
+ createdWith: {
594
+ compiler: "0.1.0",
595
+ lakebed: version
596
+ },
597
+ deployTarget: "claimed-source",
598
+ format: ANONYMOUS_ARTIFACT_FORMAT,
599
+ limits: {
600
+ instructionBudget: DEFAULT_ANONYMOUS_LIMITS.instructionBudget,
601
+ maxRowsReturned: DEFAULT_ANONYMOUS_LIMITS.rowsReturned,
602
+ maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
603
+ },
604
+ server: {
605
+ helpers: {},
606
+ imports: ["lakebed/server"],
607
+ mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
608
+ queries: Object.fromEntries(Object.keys(app.queries ?? {}).map((name) => [name, { op: "source" }])),
609
+ schema,
610
+ source: {
611
+ bytes: serverBundle.byteLength,
612
+ bundle: serverBundleBase64,
613
+ bundleHash: serverBundleHash,
614
+ entry: "/server.mjs"
615
+ }
616
+ },
617
+ source: {
618
+ files: sourceManifest,
619
+ snapshotHash: sourceSnapshotHash
620
+ }
621
+ };
622
+ const artifactHash = sha256(stableStringify(artifact));
623
+
624
+ return {
625
+ artifact,
626
+ artifactHash,
627
+ clientBundle: clientBundleBase64,
628
+ clientBundleHash
629
+ };
630
+ }
631
+
558
632
  function validateExpression(expr, path, diagnostics) {
559
633
  if (!isExpression(expr)) {
560
634
  diagnostics.push(diagnostic(path, "Expected an anonymous IR expression."));
@@ -614,6 +688,10 @@ function validateValue(value, path, diagnostics) {
614
688
  }
615
689
 
616
690
  function validateQuery(query, path, schema, diagnostics) {
691
+ if (query?.op === "source") {
692
+ return;
693
+ }
694
+
617
695
  if (!isPlainObject(query) || query.op !== "table.all" || !schema[query.table]) {
618
696
  diagnostics.push(diagnostic(path, "Invalid anonymous query IR."));
619
697
  return;
@@ -636,7 +714,7 @@ function validateQuery(query, path, schema, diagnostics) {
636
714
  }
637
715
  }
638
716
 
639
- export function validateAnonymousArtifact(artifact) {
717
+ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false } = {}) {
640
718
  const diagnostics = [];
641
719
 
642
720
  if (!isPlainObject(artifact)) {
@@ -647,7 +725,7 @@ export function validateAnonymousArtifact(artifact) {
647
725
  diagnostics.push(diagnostic("artifact.format", `Expected ${ANONYMOUS_ARTIFACT_FORMAT}.`));
648
726
  }
649
727
 
650
- if (artifact.deployTarget !== "anonymous-interpreter") {
728
+ if (artifact.deployTarget !== "anonymous-interpreter" && !(allowClaimedSource && artifact.deployTarget === "claimed-source")) {
651
729
  diagnostics.push(diagnostic("artifact.deployTarget", "Anonymous deploys require deployTarget anonymous-interpreter."));
652
730
  }
653
731
 
@@ -669,9 +747,49 @@ export function validateAnonymousArtifact(artifact) {
669
747
 
670
748
  for (const [name, query] of Object.entries(artifact.server?.queries ?? {})) {
671
749
  validateQuery(query, `artifact.server.queries.${name}`, schema ?? {}, diagnostics);
750
+ if (query?.op === "source" && artifact.server?.source === undefined) {
751
+ diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires artifact.server.source."));
752
+ }
753
+ if (query?.op === "source" && artifact.deployTarget !== "claimed-source") {
754
+ diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires deployTarget claimed-source."));
755
+ }
756
+ }
757
+
758
+ if (artifact.server?.source !== undefined) {
759
+ if (artifact.deployTarget !== "claimed-source") {
760
+ diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget claimed-source."));
761
+ }
762
+ const source = artifact.server.source;
763
+ if (
764
+ !isPlainObject(source) ||
765
+ typeof source.bundle !== "string" ||
766
+ typeof source.bundleHash !== "string" ||
767
+ !Number.isInteger(source.bytes) ||
768
+ source.bytes <= 0
769
+ ) {
770
+ diagnostics.push(diagnostic("artifact.server.source", "Invalid anonymous server source bundle."));
771
+ } else {
772
+ const bundle = Buffer.from(source.bundle, "base64");
773
+ if (bundle.byteLength !== source.bytes) {
774
+ diagnostics.push(diagnostic("artifact.server.source.bytes", "Server bundle byte count does not match artifact.server.source.bytes."));
775
+ }
776
+ if (sha256(bundle) !== source.bundleHash) {
777
+ diagnostics.push(diagnostic("artifact.server.source.bundleHash", "Server bundle hash does not match artifact.server.source.bundleHash."));
778
+ }
779
+ }
672
780
  }
673
781
 
674
782
  for (const [name, mutation] of Object.entries(artifact.server?.mutations ?? {})) {
783
+ if (mutation?.op === "source") {
784
+ if (artifact.server?.source === undefined) {
785
+ diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires artifact.server.source."));
786
+ }
787
+ if (artifact.deployTarget !== "claimed-source") {
788
+ diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires deployTarget claimed-source."));
789
+ }
790
+ continue;
791
+ }
792
+
675
793
  if (mutation?.op !== "mutation" || !Array.isArray(mutation.body)) {
676
794
  diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Invalid mutation IR."));
677
795
  continue;
@@ -704,12 +822,12 @@ export function validateAnonymousArtifact(artifact) {
704
822
  return diagnostics;
705
823
  }
706
824
 
707
- export function validateAnonymousDeployPayload(payload) {
825
+ export function validateAnonymousDeployPayload(payload, options = {}) {
708
826
  if (!payload || typeof payload !== "object") {
709
827
  throw new Error("Deploy payload must be a JSON object.");
710
828
  }
711
829
 
712
- const diagnostics = validateAnonymousArtifact(payload.artifact);
830
+ const diagnostics = validateAnonymousArtifact(payload.artifact, options);
713
831
  if (diagnostics.length > 0) {
714
832
  throw new AnonymousCompilerError(diagnostics);
715
833
  }
@@ -939,7 +1057,124 @@ async function executeQuerySpec({ args = [], artifact, auth, query, state, deplo
939
1057
  return results.slice(0, limit);
940
1058
  }
941
1059
 
1060
+ async function loadSourceApp(artifact) {
1061
+ const source = artifact.server?.source;
1062
+ if (!source) {
1063
+ return null;
1064
+ }
1065
+
1066
+ if (!sourceModuleCache.has(source.bundleHash)) {
1067
+ const url = `data:text/javascript;base64,${source.bundle}`;
1068
+ sourceModuleCache.set(source.bundleHash, import(url).then((module) => module.default));
1069
+ }
1070
+ return sourceModuleCache.get(source.bundleHash);
1071
+ }
1072
+
1073
+ function createSourceQuery(rows, tableName) {
1074
+ return {
1075
+ filters: [],
1076
+ limitValue: null,
1077
+ orderByValue: null,
1078
+ tableName,
1079
+ where(field, value) {
1080
+ return { ...this, filters: [...this.filters, { field, value }] };
1081
+ },
1082
+ orderBy(field, direction = "asc") {
1083
+ return { ...this, orderByValue: { field, direction: direction === "desc" ? "desc" : "asc" } };
1084
+ },
1085
+ limit(count) {
1086
+ return { ...this, limitValue: Number(count) };
1087
+ },
1088
+ all() {
1089
+ let results = [...rows[this.tableName]].map((row) => ({ ...row }));
1090
+ for (const filter of this.filters) {
1091
+ results = results.filter((row) => row[filter.field] === filter.value);
1092
+ }
1093
+ if (this.orderByValue) {
1094
+ const direction = this.orderByValue.direction === "desc" ? -1 : 1;
1095
+ results.sort((left, right) => compareValues(left[this.orderByValue.field], right[this.orderByValue.field]) * direction);
1096
+ }
1097
+ return results.slice(0, this.limitValue ?? results.length);
1098
+ }
1099
+ };
1100
+ }
1101
+
1102
+ async function createSourceContext({ artifact, auth, deployId, state }) {
1103
+ const rows = {};
1104
+ const operations = [];
1105
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1106
+ rows[tableName] = await state.listRows(deployId, tableName);
1107
+ }
1108
+
1109
+ const db = {};
1110
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1111
+ db[tableName] = {
1112
+ ...createSourceQuery(rows, tableName),
1113
+ get(id) {
1114
+ return rows[tableName].find((row) => row.id === id) ?? null;
1115
+ },
1116
+ insert(value) {
1117
+ const row = prepareInsert(artifact.server.schema, tableName, value);
1118
+ rows[tableName].push(row);
1119
+ operations.push({ op: "insert", row, table: tableName });
1120
+ return { ...row };
1121
+ },
1122
+ update(id, patch) {
1123
+ const index = rows[tableName].findIndex((row) => row.id === id);
1124
+ if (index === -1) {
1125
+ return;
1126
+ }
1127
+ const cleanPatch = preparePatch(artifact.server.schema, tableName, patch);
1128
+ rows[tableName][index] = { ...rows[tableName][index], ...cleanPatch };
1129
+ operations.push({ id, op: "update", patch: cleanPatch, table: tableName });
1130
+ },
1131
+ delete(id) {
1132
+ const index = rows[tableName].findIndex((row) => row.id === id);
1133
+ if (index === -1) {
1134
+ return;
1135
+ }
1136
+ rows[tableName].splice(index, 1);
1137
+ operations.push({ id, op: "delete", table: tableName });
1138
+ }
1139
+ };
1140
+ }
1141
+
1142
+ return {
1143
+ ctx: {
1144
+ auth,
1145
+ db,
1146
+ env: {},
1147
+ log: {
1148
+ error() {},
1149
+ info() {},
1150
+ warn() {}
1151
+ }
1152
+ },
1153
+ async flush(tx) {
1154
+ for (const operation of operations) {
1155
+ if (operation.op === "insert") {
1156
+ await tx.insertRow(deployId, operation.table, operation.row);
1157
+ } else if (operation.op === "update") {
1158
+ await tx.updateRow(deployId, operation.table, operation.id, operation.patch);
1159
+ } else if (operation.op === "delete") {
1160
+ await tx.deleteRow(deployId, operation.table, operation.id);
1161
+ }
1162
+ }
1163
+ }
1164
+ };
1165
+ }
1166
+
942
1167
  export async function executeAnonymousQuery({ args = [], artifact, auth, deployId, name, state }) {
1168
+ const sourceApp = await loadSourceApp(artifact);
1169
+ if (sourceApp) {
1170
+ const handler = sourceApp.queries?.[name];
1171
+ if (!handler) {
1172
+ throw new Error(`Unknown query: ${name}`);
1173
+ }
1174
+ const { ctx } = await createSourceContext({ artifact, auth, deployId, state });
1175
+ return handler(ctx, ...args);
1176
+ }
1177
+
943
1178
  const query = artifact.server.queries?.[name];
944
1179
  if (!query) {
945
1180
  throw new Error(`Unknown query: ${name}`);
@@ -958,6 +1193,20 @@ async function checkUpdateGuards({ auth, guards = [], row }) {
958
1193
  }
959
1194
 
960
1195
  export async function executeAnonymousMutation({ args = [], artifact, auth, deployId, name, state }) {
1196
+ const sourceApp = await loadSourceApp(artifact);
1197
+ if (sourceApp) {
1198
+ const handler = sourceApp.mutations?.[name];
1199
+ if (!handler) {
1200
+ throw new Error(`Unknown mutation: ${name}`);
1201
+ }
1202
+ return state.transaction(deployId, async (tx) => {
1203
+ const source = await createSourceContext({ artifact, auth, deployId, state: tx });
1204
+ const result = await handler(source.ctx, ...args);
1205
+ await source.flush(tx);
1206
+ return result ?? null;
1207
+ });
1208
+ }
1209
+
961
1210
  const mutation = artifact.server.mutations?.[name];
962
1211
  if (!mutation) {
963
1212
  throw new Error(`Unknown mutation: ${name}`);
package/src/cli.js CHANGED
@@ -9,14 +9,16 @@ import { WebSocketServer } from "ws";
9
9
  import {
10
10
  ANONYMOUS_ARTIFACT_MEDIA_TYPE,
11
11
  AnonymousCompilerError,
12
+ LAKEBED_VERSION,
12
13
  createAnonymousArtifact,
14
+ createClaimedArtifact,
13
15
  parseTtlSeconds,
14
16
  stableStringify
15
17
  } from "./anonymous.js";
16
18
  import { startAnonymousServer } from "./anonymous-server.js";
17
19
  import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
18
20
  import { LogBuffer, StateCell } from "./runtime.js";
19
- import { createMemorySourceStoreFromDirectory, sourcePathDirname, sourcePathJoin } from "./source-store.js";
21
+ import { MemorySourceStore, createMemorySourceStoreFromDirectory, sourcePathDirname, sourcePathJoin } from "./source-store.js";
20
22
 
21
23
  const root = process.cwd();
22
24
  const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -32,6 +34,7 @@ Usage:
32
34
  lakebed dev <capsule-dir> [--port 3000]
33
35
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
34
36
  lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
37
+ lakebed claim [capsule-dir] [--api <url>] [--json]
35
38
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
36
39
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
37
40
  lakebed run-many <capsule-dir> [--count 20] [--base-port 4000]
@@ -299,6 +302,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
299
302
  buildDir,
300
303
  clientOut,
301
304
  env: await readServerEnv(workingStore),
305
+ serverOut,
302
306
  sourceStore: workingStore
303
307
  };
304
308
  }
@@ -598,6 +602,95 @@ async function buildAnonymousEnvelope(capsuleArg) {
598
602
  };
599
603
  }
600
604
 
605
+ const claimRequiredDiagnosticMessages = new Set([
606
+ "Outbound fetch is disabled for anonymous deploys.",
607
+ "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
608
+ ]);
609
+
610
+ function canDeployAfterClaim(error) {
611
+ return (
612
+ error instanceof AnonymousCompilerError &&
613
+ error.diagnostics.length > 0 &&
614
+ error.diagnostics.every((entry) => claimRequiredDiagnosticMessages.has(entry.message))
615
+ );
616
+ }
617
+
618
+ async function buildClaimRequiredEnvelope({ capsuleDir }) {
619
+ const sourceStore = new MemorySourceStore();
620
+ await sourceStore.writeFile(
621
+ "server/index.ts",
622
+ `import { capsule } from "lakebed/server";
623
+
624
+ export default capsule({
625
+ name: "Claim Required",
626
+ schema: {},
627
+ queries: {},
628
+ mutations: {}
629
+ });
630
+ `
631
+ );
632
+ await sourceStore.writeFile(
633
+ "client/index.tsx",
634
+ `export function App() {
635
+ return (
636
+ <main className="min-h-screen bg-neutral-950 px-6 py-12 text-neutral-100">
637
+ <section className="mx-auto max-w-2xl rounded-lg border border-neutral-800 bg-neutral-900 p-8 shadow-2xl">
638
+ <p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
639
+ <h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
640
+ <p className="mt-4 text-neutral-300">
641
+ This capsule uses server-side fetch. Claim this deploy, then run lakebed deploy again to publish the app.
642
+ </p>
643
+ </section>
644
+ </main>
645
+ );
646
+ }
647
+ `
648
+ );
649
+ const built = await buildCapsule({
650
+ capsuleDir,
651
+ capsuleId: `claim-required-${Date.now()}`,
652
+ sourceStore
653
+ });
654
+ const artifact = await createAnonymousArtifact({
655
+ app: built.app,
656
+ clientOut: built.clientOut,
657
+ sourceStore
658
+ });
659
+
660
+ return {
661
+ artifact: artifact.artifact,
662
+ artifactHash: artifact.artifactHash,
663
+ clientBundle: artifact.clientBundle,
664
+ clientBundleHash: artifact.clientBundleHash,
665
+ claimRequired: true,
666
+ mediaType: ANONYMOUS_ARTIFACT_MEDIA_TYPE
667
+ };
668
+ }
669
+
670
+ async function buildClaimedEnvelope(capsuleArg) {
671
+ const capsuleDir = resolveCapsuleDir(capsuleArg);
672
+ const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
673
+ const built = await buildCapsule({
674
+ capsuleDir,
675
+ capsuleId: `claimed-${Date.now()}`,
676
+ sourceStore
677
+ });
678
+ const artifact = await createClaimedArtifact({
679
+ app: built.app,
680
+ clientOut: built.clientOut,
681
+ serverOut: built.serverOut,
682
+ sourceStore
683
+ });
684
+
685
+ return {
686
+ artifact: artifact.artifact,
687
+ artifactHash: artifact.artifactHash,
688
+ clientBundle: artifact.clientBundle,
689
+ clientBundleHash: artifact.clientBundleHash,
690
+ mediaType: ANONYMOUS_ARTIFACT_MEDIA_TYPE
691
+ };
692
+ }
693
+
601
694
  function defaultArtifactPath(capsuleDir) {
602
695
  return resolve(root, ".lakebed/artifacts", `${basename(capsuleDir)}.anonymous.json`);
603
696
  }
@@ -642,11 +735,19 @@ function claimTokenFromDeployResponse(deployed) {
642
735
  return null;
643
736
  }
644
737
 
738
+ function claimUrlFromDeployMetadata(metadata) {
739
+ if (!metadata?.api || !metadata?.deployId || !metadata?.claimToken) {
740
+ return null;
741
+ }
742
+
743
+ return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
744
+ }
745
+
645
746
  function deployRequestBody(envelope, ttl) {
646
747
  return JSON.stringify({
647
748
  artifact: envelope.artifact,
648
749
  clientBundle: envelope.clientBundle,
649
- clientVersion: "0.0.3",
750
+ clientVersion: LAKEBED_VERSION,
650
751
  requestedTtlSeconds: ttl
651
752
  });
652
753
  }
@@ -700,13 +801,35 @@ async function readResponseJson(response) {
700
801
  async function deployCommand(args) {
701
802
  const [capsuleArg] = positionals(args);
702
803
  const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
703
- const envelope = await buildAnonymousEnvelope(capsuleDir);
704
804
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
705
805
  const api = deployApiUrl(args);
706
- const body = deployRequestBody(envelope, ttl);
707
806
  const metadata = await readDeployMetadata(capsuleDir);
708
807
  const canUpdate =
709
808
  metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
809
+ let currentDeploy = null;
810
+ if (canUpdate) {
811
+ const currentResponse = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`);
812
+ if (currentResponse.ok) {
813
+ currentDeploy = await currentResponse.json();
814
+ }
815
+ }
816
+
817
+ let envelope;
818
+ try {
819
+ envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir) : await buildAnonymousEnvelope(capsuleDir);
820
+ } catch (error) {
821
+ if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
822
+ throw new Error(
823
+ `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
824
+ );
825
+ }
826
+ if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
827
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir });
828
+ } else {
829
+ throw error;
830
+ }
831
+ }
832
+ const body = deployRequestBody(envelope, ttl);
710
833
  let mode = "created";
711
834
  let response;
712
835
 
@@ -748,11 +871,15 @@ async function deployCommand(args) {
748
871
  }
749
872
 
750
873
  if (hasFlag(args, "--json")) {
751
- console.log(JSON.stringify(deployed, null, 2));
874
+ console.log(JSON.stringify(envelope.claimRequired ? { ...deployed, claimRequired: true } : deployed, null, 2));
752
875
  return;
753
876
  }
754
877
 
755
- console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
878
+ if (envelope.claimRequired && mode !== "updated") {
879
+ console.log("Created claim-required preview.\n");
880
+ } else {
881
+ console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
882
+ }
756
883
  console.log(`App: ${deployed.url}`);
757
884
  console.log(`Expires: ${deployed.expiresAt}`);
758
885
  if (deployed.claimUrl) {
@@ -764,7 +891,64 @@ async function deployCommand(args) {
764
891
  console.log(` state: ${deployed.limits.stateBytes} bytes`);
765
892
  console.log(` requests: ${deployed.limits.requestsPerDay} / day`);
766
893
  console.log(` mutations: ${deployed.limits.mutationsPerDay} / day`);
767
- console.log(" outbound fetch: disabled");
894
+ console.log(` outbound fetch: ${envelope.artifact.deployTarget === "claimed-source" ? "enabled" : "disabled"}`);
895
+ if (envelope.claimRequired) {
896
+ console.log("\nThis app needs a claimed deploy before server-side fetch can run.");
897
+ console.log("Open the claim URL, then run lakebed deploy again.");
898
+ }
899
+ }
900
+
901
+ async function claimCommand(args) {
902
+ const [capsuleArg] = positionals(args);
903
+ const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
904
+ const api = deployApiUrl(args);
905
+ const metadata = await readDeployMetadata(capsuleDir);
906
+
907
+ if (!metadata) {
908
+ throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
909
+ }
910
+
911
+ if (metadata.api !== api) {
912
+ throw new Error(`Saved deploy metadata is for ${metadata.api}, but this command is using ${api}. Pass --api ${metadata.api} to claim it.`);
913
+ }
914
+
915
+ const claimUrl = claimUrlFromDeployMetadata(metadata);
916
+ if (!claimUrl) {
917
+ throw new Error("This project does not have a saved claim token. Redeploy to create a new claim URL.");
918
+ }
919
+
920
+ let deploy = null;
921
+ try {
922
+ const response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`);
923
+ if (response.ok) {
924
+ deploy = await response.json();
925
+ }
926
+ } catch {
927
+ deploy = null;
928
+ }
929
+
930
+ const result = {
931
+ claimed: Boolean(deploy?.claimed),
932
+ claimUrl,
933
+ deployId: metadata.deployId,
934
+ url: deploy?.url ?? metadata.url
935
+ };
936
+
937
+ if (hasFlag(args, "--json")) {
938
+ console.log(JSON.stringify(result, null, 2));
939
+ return;
940
+ }
941
+
942
+ if (result.claimed) {
943
+ console.log(`Deploy ${result.deployId} is already claimed.`);
944
+ if (result.url) {
945
+ console.log(`App: ${result.url}`);
946
+ }
947
+ return;
948
+ }
949
+
950
+ console.log("Open this URL to claim the current project's deploy:");
951
+ console.log(claimUrl);
768
952
  }
769
953
 
770
954
  async function anonymousServerCommand(args) {
@@ -931,7 +1115,7 @@ lakebed logs --port 3000
931
1115
  - One client entry.
932
1116
  - Guest auth locally, with built-in Google sign-in through Shoo.
933
1117
  - No file storage.
934
- - No outbound fetch in anonymous deploys.
1118
+ - No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
935
1119
  - Local state resets when \`lakebed dev\` restarts.
936
1120
  `;
937
1121
  }
@@ -1146,6 +1330,11 @@ async function main() {
1146
1330
  return;
1147
1331
  }
1148
1332
 
1333
+ if (command === "claim") {
1334
+ await claimCommand(args);
1335
+ return;
1336
+ }
1337
+
1149
1338
  if (command === "anonymous-server") {
1150
1339
  await anonymousServerCommand(args);
1151
1340
  return;
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const LAKEBED_VERSION = "0.0.5";