lakebed 0.0.5 → 0.0.7

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/src/anonymous.js CHANGED
@@ -6,6 +6,14 @@ export const ANONYMOUS_ARTIFACT_FORMAT = "lakebed.capsule.artifact.v1";
6
6
  export const ANONYMOUS_ARTIFACT_MEDIA_TYPE = "application/vnd.lakebed.artifact+json";
7
7
  export { LAKEBED_VERSION };
8
8
 
9
+ export const SERVER_ENV_FILE = ".env.lakebed.server";
10
+ export const SERVER_ENV_LIMITS = {
11
+ maxKeyBytes: 128,
12
+ maxKeys: 64,
13
+ maxTotalBytes: 64 * 1024,
14
+ maxValueBytes: 16 * 1024
15
+ };
16
+
9
17
  export const DEFAULT_ANONYMOUS_LIMITS = {
10
18
  artifactBytes: 1024 * 1024,
11
19
  stateBytes: 1024 * 1024,
@@ -67,6 +75,65 @@ function isPlainObject(value) {
67
75
  return Boolean(value) && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
68
76
  }
69
77
 
78
+ export function validateServerEnvValues(values, path = "serverEnv.values") {
79
+ if (!isPlainObject(values)) {
80
+ throw new Error(`${path} must be a JSON object.`);
81
+ }
82
+
83
+ const entries = Object.entries(values).sort(([left], [right]) => left.localeCompare(right));
84
+ if (entries.length > SERVER_ENV_LIMITS.maxKeys) {
85
+ throw new Error(`${path} may include at most ${SERVER_ENV_LIMITS.maxKeys} keys.`);
86
+ }
87
+
88
+ const env = {};
89
+ let totalBytes = 0;
90
+ for (const [key, value] of entries) {
91
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
92
+ throw new Error(`${path}.${key} is not a valid server env key.`);
93
+ }
94
+ if (key.startsWith("LAKEBED_")) {
95
+ throw new Error(`${path}.${key} uses the reserved LAKEBED_ prefix.`);
96
+ }
97
+ if (typeof value !== "string") {
98
+ throw new Error(`${path}.${key} must be a string.`);
99
+ }
100
+
101
+ const keyBytes = byteLength(key);
102
+ const valueBytes = byteLength(value);
103
+ if (keyBytes > SERVER_ENV_LIMITS.maxKeyBytes) {
104
+ throw new Error(`${path}.${key} exceeds ${SERVER_ENV_LIMITS.maxKeyBytes} bytes.`);
105
+ }
106
+ if (valueBytes > SERVER_ENV_LIMITS.maxValueBytes) {
107
+ throw new Error(`${path}.${key} exceeds ${SERVER_ENV_LIMITS.maxValueBytes} bytes.`);
108
+ }
109
+
110
+ totalBytes += keyBytes + valueBytes;
111
+ env[key] = value;
112
+ }
113
+
114
+ if (totalBytes > SERVER_ENV_LIMITS.maxTotalBytes) {
115
+ throw new Error(`${path} exceeds ${SERVER_ENV_LIMITS.maxTotalBytes} bytes.`);
116
+ }
117
+
118
+ return env;
119
+ }
120
+
121
+ export function validateServerEnvPayload(payload) {
122
+ if (payload === undefined) {
123
+ return undefined;
124
+ }
125
+
126
+ if (!isPlainObject(payload)) {
127
+ throw new Error("serverEnv must be a JSON object.");
128
+ }
129
+
130
+ if (payload.mode !== "replace") {
131
+ throw new Error("serverEnv.mode must be replace.");
132
+ }
133
+
134
+ return validateServerEnvValues(payload.values);
135
+ }
136
+
70
137
  function isExpression(value) {
71
138
  return Array.isArray(value) && expressionOps.has(value[0]);
72
139
  }
@@ -317,7 +384,7 @@ function diagnostic(file, message) {
317
384
  }
318
385
 
319
386
  async function readSourceFiles(sourceStore) {
320
- const paths = (await sourceStore.listFiles()).filter((path) => !path.startsWith("__lakebed/"));
387
+ const paths = (await sourceStore.listFiles()).filter((path) => !path.startsWith("__lakebed/") && path !== SERVER_ENV_FILE);
321
388
  const files = [];
322
389
 
323
390
  for (const path of paths) {
@@ -843,12 +910,14 @@ export function validateAnonymousDeployPayload(payload, options = {}) {
843
910
  }
844
911
 
845
912
  const artifactHash = sha256(stableStringify(payload.artifact));
913
+ const serverEnv = validateServerEnvPayload(payload.serverEnv);
846
914
  return {
847
915
  artifact: cloneJson(payload.artifact),
848
916
  artifactHash,
849
917
  clientBundle,
850
918
  clientBundleBase64: clientBundle.toString("base64"),
851
- clientBundleHash
919
+ clientBundleHash,
920
+ serverEnv
852
921
  };
853
922
  }
854
923
 
@@ -1102,6 +1171,7 @@ function createSourceQuery(rows, tableName) {
1102
1171
  async function createSourceContext({ artifact, auth, deployId, state }) {
1103
1172
  const rows = {};
1104
1173
  const operations = [];
1174
+ const env = typeof state.getServerEnv === "function" ? await state.getServerEnv(deployId) : {};
1105
1175
  for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1106
1176
  rows[tableName] = await state.listRows(deployId, tableName);
1107
1177
  }
@@ -1143,7 +1213,7 @@ async function createSourceContext({ artifact, auth, deployId, state }) {
1143
1213
  ctx: {
1144
1214
  auth,
1145
1215
  db,
1146
- env: {},
1216
+ env,
1147
1217
  log: {
1148
1218
  error() {},
1149
1219
  info() {},
package/src/cli.js CHANGED
@@ -10,10 +10,12 @@ import {
10
10
  ANONYMOUS_ARTIFACT_MEDIA_TYPE,
11
11
  AnonymousCompilerError,
12
12
  LAKEBED_VERSION,
13
+ SERVER_ENV_FILE,
13
14
  createAnonymousArtifact,
14
15
  createClaimedArtifact,
15
16
  parseTtlSeconds,
16
- stableStringify
17
+ stableStringify,
18
+ validateServerEnvValues
17
19
  } from "./anonymous.js";
18
20
  import { startAnonymousServer } from "./anonymous-server.js";
19
21
  import { authFromUrl as resolveAuthFromUrl, createGuestAuth, requestOrigin, shooBaseUrlFromEnv } from "./auth.js";
@@ -221,29 +223,45 @@ function createSourcePlugin(sourceStore, target) {
221
223
  };
222
224
  }
223
225
 
224
- async function readServerEnv(sourceStore) {
225
- const env = { ...process.env };
226
- if (!sourceStore.hasFile(".env.lakebed.server")) {
227
- return env;
226
+ function unquoteServerEnvValue(value) {
227
+ if (value.length < 2) {
228
+ return value;
228
229
  }
229
230
 
230
- const contents = await sourceStore.readFile(".env.lakebed.server");
231
- for (const rawLine of contents.split(/\r?\n/)) {
231
+ const quote = value[0];
232
+ if ((quote !== `"` && quote !== `'`) || value[value.length - 1] !== quote) {
233
+ return value;
234
+ }
235
+
236
+ return value.slice(1, -1);
237
+ }
238
+
239
+ function parseServerEnvFile(contents) {
240
+ const env = {};
241
+ for (const [index, rawLine] of contents.split(/\r?\n/).entries()) {
232
242
  const line = rawLine.trim();
233
243
  if (!line || line.startsWith("#")) {
234
244
  continue;
235
245
  }
236
246
 
237
- const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
247
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
238
248
  if (!match) {
239
- continue;
249
+ throw new Error(`Invalid ${SERVER_ENV_FILE} line ${index + 1}. Use KEY=value.`);
240
250
  }
241
251
 
242
252
  const [, key, rawValue] = match;
243
- env[key] = rawValue.replace(/^['"]|['"]$/g, "");
253
+ env[key] = unquoteServerEnvValue(rawValue.trim());
244
254
  }
245
255
 
246
- return env;
256
+ return validateServerEnvValues(env, SERVER_ENV_FILE);
257
+ }
258
+
259
+ async function readCapsuleServerEnv(sourceStore) {
260
+ if (!sourceStore.hasFile(SERVER_ENV_FILE)) {
261
+ return {};
262
+ }
263
+
264
+ return parseServerEnvFile(await sourceStore.readFile(SERVER_ENV_FILE));
247
265
  }
248
266
 
249
267
  export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev" } = {}) {
@@ -301,7 +319,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
301
319
  app: capsuleModule.default,
302
320
  buildDir,
303
321
  clientOut,
304
- env: await readServerEnv(workingStore),
322
+ env: await readCapsuleServerEnv(workingStore),
305
323
  serverOut,
306
324
  sourceStore: workingStore
307
325
  };
@@ -579,9 +597,9 @@ async function dev(args) {
579
597
  });
580
598
  }
581
599
 
582
- async function buildAnonymousEnvelope(capsuleArg) {
600
+ async function buildAnonymousEnvelope(capsuleArg, sourceStore) {
583
601
  const capsuleDir = resolveCapsuleDir(capsuleArg);
584
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
602
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
585
603
  const built = await buildCapsule({
586
604
  capsuleDir,
587
605
  capsuleId: `anonymous-${Date.now()}`,
@@ -615,7 +633,7 @@ function canDeployAfterClaim(error) {
615
633
  );
616
634
  }
617
635
 
618
- async function buildClaimRequiredEnvelope({ capsuleDir }) {
636
+ async function buildClaimRequiredEnvelope({ capsuleDir, feature = "claimed server features" }) {
619
637
  const sourceStore = new MemorySourceStore();
620
638
  await sourceStore.writeFile(
621
639
  "server/index.ts",
@@ -638,7 +656,7 @@ export default capsule({
638
656
  <p className="text-sm font-semibold uppercase tracking-wide text-cyan-300">Lakebed deploy</p>
639
657
  <h1 className="mt-3 text-3xl font-semibold">Claim required</h1>
640
658
  <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.
659
+ This capsule uses ${feature}. Claim this deploy, then run lakebed deploy again to publish the app.
642
660
  </p>
643
661
  </section>
644
662
  </main>
@@ -667,9 +685,9 @@ export default capsule({
667
685
  };
668
686
  }
669
687
 
670
- async function buildClaimedEnvelope(capsuleArg) {
688
+ async function buildClaimedEnvelope(capsuleArg, sourceStore) {
671
689
  const capsuleDir = resolveCapsuleDir(capsuleArg);
672
- const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
690
+ sourceStore ??= await createMemorySourceStoreFromDirectory(capsuleDir);
673
691
  const built = await buildCapsule({
674
692
  capsuleDir,
675
693
  capsuleId: `claimed-${Date.now()}`,
@@ -743,13 +761,21 @@ function claimUrlFromDeployMetadata(metadata) {
743
761
  return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
744
762
  }
745
763
 
746
- function deployRequestBody(envelope, ttl) {
747
- return JSON.stringify({
764
+ function deployRequestBody(envelope, ttl, { serverEnv } = {}) {
765
+ const body = {
748
766
  artifact: envelope.artifact,
749
767
  clientBundle: envelope.clientBundle,
750
768
  clientVersion: LAKEBED_VERSION,
751
769
  requestedTtlSeconds: ttl
752
- });
770
+ };
771
+ if (serverEnv !== undefined) {
772
+ body.serverEnv = {
773
+ mode: "replace",
774
+ values: serverEnv
775
+ };
776
+ }
777
+
778
+ return JSON.stringify(body);
753
779
  }
754
780
 
755
781
  async function buildCommand(args) {
@@ -801,6 +827,11 @@ async function readResponseJson(response) {
801
827
  async function deployCommand(args) {
802
828
  const [capsuleArg] = positionals(args);
803
829
  const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
830
+ const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
831
+ const serverEnvFileExists = sourceStore.hasFile(SERVER_ENV_FILE);
832
+ const serverEnv = await readCapsuleServerEnv(sourceStore);
833
+ const serverEnvKeys = Object.keys(serverEnv).sort();
834
+ const hasServerEnvValues = serverEnvKeys.length > 0;
804
835
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
805
836
  const api = deployApiUrl(args);
806
837
  const metadata = await readDeployMetadata(capsuleDir);
@@ -815,27 +846,45 @@ async function deployCommand(args) {
815
846
  }
816
847
 
817
848
  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) {
849
+ if (!currentDeploy?.claimed && hasServerEnvValues) {
850
+ if (canUpdate && currentDeploy) {
822
851
  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.`
852
+ `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.`
824
853
  );
825
854
  }
826
- if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
827
- envelope = await buildClaimRequiredEnvelope({ capsuleDir });
828
- } else {
829
- throw error;
855
+ try {
856
+ await buildAnonymousEnvelope(capsuleDir, sourceStore);
857
+ } catch (error) {
858
+ if (!canDeployAfterClaim(error)) {
859
+ throw error;
860
+ }
861
+ }
862
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server env" });
863
+ } else {
864
+ try {
865
+ envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir, sourceStore) : await buildAnonymousEnvelope(capsuleDir, sourceStore);
866
+ } catch (error) {
867
+ if (error instanceof AnonymousCompilerError && canUpdate && currentDeploy && !currentDeploy.claimed) {
868
+ throw new Error(
869
+ `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
870
+ );
871
+ }
872
+ if ((!canUpdate || !currentDeploy) && canDeployAfterClaim(error)) {
873
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server-side fetch" });
874
+ } else {
875
+ throw error;
876
+ }
830
877
  }
831
878
  }
832
- const body = deployRequestBody(envelope, ttl);
879
+ const syncServerEnv = Boolean(currentDeploy?.claimed && serverEnvFileExists);
880
+ const serverEnvForUpdate = syncServerEnv ? serverEnv : undefined;
881
+ let serverEnvSynced = false;
833
882
  let mode = "created";
834
883
  let response;
835
884
 
836
885
  if (canUpdate) {
837
886
  response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
838
- body,
887
+ body: deployRequestBody(envelope, ttl, { serverEnv: serverEnvForUpdate }),
839
888
  headers: {
840
889
  "Authorization": `Bearer ${metadata.claimToken}`,
841
890
  "Content-Type": "application/json"
@@ -846,13 +895,17 @@ async function deployCommand(args) {
846
895
  if (response.status === 404 || response.status === 410) {
847
896
  mode = "created";
848
897
  response = null;
898
+ if (serverEnvForUpdate !== undefined && hasServerEnvValues) {
899
+ envelope = await buildClaimRequiredEnvelope({ capsuleDir, feature: "server env" });
900
+ }
849
901
  } else {
850
902
  mode = "updated";
903
+ serverEnvSynced = serverEnvForUpdate !== undefined;
851
904
  }
852
905
  }
853
906
 
854
907
  response ??= await fetch(`${api}/v1/anonymous-deploys`, {
855
- body,
908
+ body: deployRequestBody(envelope, ttl),
856
909
  headers: {
857
910
  "Content-Type": "application/json"
858
911
  },
@@ -871,7 +924,16 @@ async function deployCommand(args) {
871
924
  }
872
925
 
873
926
  if (hasFlag(args, "--json")) {
874
- console.log(JSON.stringify(envelope.claimRequired ? { ...deployed, claimRequired: true } : deployed, null, 2));
927
+ console.log(
928
+ JSON.stringify(
929
+ {
930
+ ...(envelope.claimRequired ? { ...deployed, claimRequired: true } : deployed),
931
+ ...(serverEnvSynced ? { serverEnv: { keys: serverEnvKeys, mode: "replace", synced: true } } : {})
932
+ },
933
+ null,
934
+ 2
935
+ )
936
+ );
875
937
  return;
876
938
  }
877
939
 
@@ -892,8 +954,14 @@ async function deployCommand(args) {
892
954
  console.log(` requests: ${deployed.limits.requestsPerDay} / day`);
893
955
  console.log(` mutations: ${deployed.limits.mutationsPerDay} / day`);
894
956
  console.log(` outbound fetch: ${envelope.artifact.deployTarget === "claimed-source" ? "enabled" : "disabled"}`);
957
+ if (serverEnvSynced) {
958
+ console.log(`\nServer env: ${serverEnvKeys.length} synced`);
959
+ for (const key of serverEnvKeys) {
960
+ console.log(` ${key}`);
961
+ }
962
+ }
895
963
  if (envelope.claimRequired) {
896
- console.log("\nThis app needs a claimed deploy before server-side fetch can run.");
964
+ console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
897
965
  console.log("Open the claim URL, then run lakebed deploy again.");
898
966
  }
899
967
  }
@@ -1106,6 +1174,7 @@ lakebed logs --port 3000
1106
1174
  - Use Tailwind classes directly in JSX.
1107
1175
  - Do not add a CSS, PostCSS, or Tailwind build pipeline.
1108
1176
  - Use auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
1177
+ - Read server-only environment variables through \`ctx.env\`; define them in \`.env.lakebed.server\`.
1109
1178
  - Add Google sign-in with \`<SignInWithGoogle />\` or \`signInWithGoogle()\` from \`lakebed/client\`.
1110
1179
  - Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
1111
1180
 
@@ -1116,6 +1185,7 @@ lakebed logs --port 3000
1116
1185
  - Guest auth locally, with built-in Google sign-in through Shoo.
1117
1186
  - No file storage.
1118
1187
  - No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
1188
+ - Non-empty \`.env.lakebed.server\` files sync only after a deploy is claimed.
1119
1189
  - Local state resets when \`lakebed dev\` restarts.
1120
1190
  `;
1121
1191
  }
@@ -1223,6 +1293,7 @@ export function cleanTodoText(value: string): string {
1223
1293
  }
1224
1294
  `,
1225
1295
  ".gitignore": `.lakebed/
1296
+ .env.lakebed.server
1226
1297
  `,
1227
1298
  "README.md": `# ${title}
1228
1299
 
package/src/client.js CHANGED
@@ -19,6 +19,7 @@ const pending = new Map();
19
19
  const activeSubscriptions = new Set();
20
20
  let authInitPromise = null;
21
21
  let authInitialized = false;
22
+ let refreshRequested = false;
22
23
 
23
24
  function toGuestName(name) {
24
25
  return (
@@ -68,6 +69,15 @@ function emitQuery(name, value) {
68
69
  }
69
70
  }
70
71
 
72
+ function refreshPage() {
73
+ if (refreshRequested) {
74
+ return;
75
+ }
76
+
77
+ refreshRequested = true;
78
+ window.location.reload();
79
+ }
80
+
71
81
  function send(message) {
72
82
  const ws = connect();
73
83
  const payload = JSON.stringify(message);
@@ -438,6 +448,11 @@ function connect() {
438
448
  return;
439
449
  }
440
450
 
451
+ if (message.op === "refresh") {
452
+ refreshPage();
453
+ return;
454
+ }
455
+
441
456
  if (message.id && pending.has(message.id)) {
442
457
  const handlers = pending.get(message.id);
443
458
  pending.delete(message.id);
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.5";
1
+ export const LAKEBED_VERSION = "0.0.7";