lakebed 0.0.13 → 0.0.14

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
@@ -176,7 +176,7 @@ LAKEBED_SESSION_SECRET=...
176
176
  LAKEBED_SERVER_ENV_SECRET=...
177
177
  ```
178
178
 
179
- 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` or hosted server env; after a deploy is claimed, `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, `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. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
179
+ Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys and do not expire. Anonymous deploys cannot use outbound `fetch` or hosted server env; after a deploy is claimed, `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, `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. Set `LAKEBED_SERVER_ENV_SECRET` on Postgres-backed runners to encrypt stored server env values.
180
180
 
181
181
  ## Admin Dashboard
182
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -190,11 +190,16 @@ function responseForDeploy({ deploy, token }) {
190
190
  expiresAt: deploy.expiresAt,
191
191
  inspect: inspectUrls(deploy.url),
192
192
  limits: deploy.limits,
193
+ updatedAt: deploy.updatedAt,
193
194
  url: deploy.url
194
195
  };
195
196
  }
196
197
 
197
198
  function isExpired(deploy) {
199
+ if (deploy?.ownerId) {
200
+ return false;
201
+ }
202
+
198
203
  return Boolean(deploy.expiresAt) && Date.parse(deploy.expiresAt) <= Date.now();
199
204
  }
200
205
 
@@ -705,6 +710,7 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
705
710
  stateRows,
706
711
  status: isExpired(deploy) ? "expired" : deploy.status,
707
712
  tableCount: Object.keys(artifact?.server?.schema ?? {}).length,
713
+ updatedAt: deploy.updatedAt,
708
714
  url: deploy.url,
709
715
  ...usageCounts(usage)
710
716
  };
@@ -1286,6 +1292,7 @@ function adminHtml() {
1286
1292
  <th>User</th>
1287
1293
  <th>Status</th>
1288
1294
  <th>Created</th>
1295
+ <th>Updated</th>
1289
1296
  <th>Expires</th>
1290
1297
  <th>Usage</th>
1291
1298
  <th>Storage</th>
@@ -1389,6 +1396,7 @@ function adminHtml() {
1389
1396
  <th>User</th>
1390
1397
  <th>Status</th>
1391
1398
  <th>Created</th>
1399
+ <th>Updated</th>
1392
1400
  <th>Expires</th>
1393
1401
  <th>Usage</th>
1394
1402
  <th>Storage</th>
@@ -1464,6 +1472,10 @@ function adminHtml() {
1464
1472
  }).format(new Date(value));
1465
1473
  }
1466
1474
 
1475
+ function formatExpiry(value) {
1476
+ return value ? formatTime(value) : "never";
1477
+ }
1478
+
1467
1479
  function plural(count, label) {
1468
1480
  return formatNumber(count) + " " + label + (count === 1 ? "" : "s");
1469
1481
  }
@@ -1715,7 +1727,7 @@ function adminHtml() {
1715
1727
  if (!deploys.length) {
1716
1728
  const tr = document.createElement("tr");
1717
1729
  const td = textCell(emptyText, "mono");
1718
- td.colSpan = 9;
1730
+ td.colSpan = 10;
1719
1731
  tr.appendChild(td);
1720
1732
  tbody.appendChild(tr);
1721
1733
  return;
@@ -1727,7 +1739,8 @@ function adminHtml() {
1727
1739
  tr.appendChild(deployUserCell(deploy));
1728
1740
  tr.appendChild(statusCell(deploy));
1729
1741
  tr.appendChild(textCell(formatTime(deploy.createdAt), "mono"));
1730
- tr.appendChild(textCell(formatTime(deploy.expiresAt), "mono"));
1742
+ tr.appendChild(textCell(formatTime(deploy.updatedAt), "mono"));
1743
+ tr.appendChild(textCell(formatExpiry(deploy.expiresAt), "mono"));
1731
1744
  tr.appendChild(usageCell(deploy));
1732
1745
  tr.appendChild(storageCell(deploy));
1733
1746
  tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
@@ -2207,6 +2220,14 @@ function escapeHtml(value) {
2207
2220
  .replace(/"/g, "&quot;");
2208
2221
  }
2209
2222
 
2223
+ function formatHtmlTime(value) {
2224
+ return value ? new Date(value).toLocaleString() : "unknown";
2225
+ }
2226
+
2227
+ function formatHtmlExpiry(value) {
2228
+ return value ? formatHtmlTime(value) : "never";
2229
+ }
2230
+
2210
2231
  function developerDeploySummary({ artifact, deploy, usage }) {
2211
2232
  return {
2212
2233
  artifactHash: deploy.artifactHash,
@@ -2221,6 +2242,7 @@ function developerDeploySummary({ artifact, deploy, usage }) {
2221
2242
  ownerId: deploy.ownerId,
2222
2243
  slug: deploy.slug,
2223
2244
  status: isExpired(deploy) ? "expired" : deploy.status,
2245
+ updatedAt: deploy.updatedAt,
2224
2246
  url: deploy.url,
2225
2247
  usage: usageCounts(usage)
2226
2248
  };
@@ -2233,8 +2255,9 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2233
2255
  (deploy) => `<tr>
2234
2256
  <td><a href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a><small>${escapeHtml(deploy.deployId)} / ${escapeHtml(deploy.slug)}</small></td>
2235
2257
  <td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
2236
- <td>${escapeHtml(new Date(deploy.createdAt).toLocaleString())}</td>
2237
- <td>${escapeHtml(new Date(deploy.expiresAt).toLocaleString())}</td>
2258
+ <td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
2259
+ <td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
2260
+ <td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
2238
2261
  <td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
2239
2262
  <td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
2240
2263
  </tr>`
@@ -2434,6 +2457,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
2434
2457
  <th>Deploy</th>
2435
2458
  <th>Status</th>
2436
2459
  <th>Created</th>
2460
+ <th>Updated</th>
2437
2461
  <th>Expires</th>
2438
2462
  <th>Requests</th>
2439
2463
  <th>Mutations</th>
@@ -2622,6 +2646,7 @@ export class MemoryAnonymousStore {
2622
2646
  publicRootUrl,
2623
2647
  slug,
2624
2648
  status: "active",
2649
+ updatedAt: createdAt,
2625
2650
  url
2626
2651
  };
2627
2652
  this.deploys.set(deployId, deploy);
@@ -2648,7 +2673,7 @@ export class MemoryAnonymousStore {
2648
2673
  return null;
2649
2674
  }
2650
2675
 
2651
- const createdAt = now();
2676
+ const updatedAt = now();
2652
2677
  const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
2653
2678
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
2654
2679
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
@@ -2657,10 +2682,11 @@ export class MemoryAnonymousStore {
2657
2682
  appBaseDomain: nextAppBaseDomain,
2658
2683
  artifactHash,
2659
2684
  clientBundleHash,
2660
- expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
2685
+ expiresAt: currentDeploy.ownerId ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString(),
2661
2686
  publicRootUrl: nextPublicRootUrl,
2662
2687
  url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
2663
- status: "active"
2688
+ status: "active",
2689
+ updatedAt
2664
2690
  };
2665
2691
 
2666
2692
  this.storeArtifact({
@@ -2668,7 +2694,7 @@ export class MemoryAnonymousStore {
2668
2694
  artifactHash,
2669
2695
  clientBundleBase64,
2670
2696
  clientBundleHash,
2671
- createdAt
2697
+ createdAt: updatedAt
2672
2698
  });
2673
2699
  this.deploys.set(deployId, deploy);
2674
2700
  if (serverEnv !== undefined) {
@@ -2691,11 +2717,14 @@ export class MemoryAnonymousStore {
2691
2717
  return { deploy: currentDeploy, status: "conflict" };
2692
2718
  }
2693
2719
 
2720
+ const updatedAt = now();
2694
2721
  const deploy = {
2695
2722
  ...currentDeploy,
2696
- claimedAt: currentDeploy.claimedAt ?? now(),
2723
+ claimedAt: currentDeploy.claimedAt ?? updatedAt,
2724
+ expiresAt: null,
2697
2725
  owner,
2698
- ownerId: owner.id
2726
+ ownerId: owner.id,
2727
+ updatedAt
2699
2728
  };
2700
2729
  await this.rememberUser(owner);
2701
2730
  this.deploys.set(deployId, deploy);
@@ -2710,7 +2739,8 @@ export class MemoryAnonymousStore {
2710
2739
 
2711
2740
  const deploy = {
2712
2741
  ...currentDeploy,
2713
- status: "terminated"
2742
+ status: "terminated",
2743
+ updatedAt: now()
2714
2744
  };
2715
2745
  this.deploys.set(deployId, deploy);
2716
2746
  return this.deployWithUserLimitOverrides(deploy);
@@ -2938,7 +2968,8 @@ export class PostgresAnonymousStore {
2938
2968
  artifact_hash text not null,
2939
2969
  client_bundle_hash text not null,
2940
2970
  created_at timestamptz not null,
2941
- expires_at timestamptz not null,
2971
+ updated_at timestamptz not null,
2972
+ expires_at timestamptz,
2942
2973
  claimed_at timestamptz,
2943
2974
  owner_id text,
2944
2975
  owner_json jsonb,
@@ -2953,6 +2984,11 @@ export class PostgresAnonymousStore {
2953
2984
  await this.query("alter table deploys add column if not exists claimed_at timestamptz");
2954
2985
  await this.query("alter table deploys add column if not exists owner_id text");
2955
2986
  await this.query("alter table deploys add column if not exists owner_json jsonb");
2987
+ await this.query("alter table deploys add column if not exists updated_at timestamptz");
2988
+ await this.query("update deploys set updated_at = created_at where updated_at is null");
2989
+ await this.query("alter table deploys alter column updated_at set not null");
2990
+ await this.query("alter table deploys alter column expires_at drop not null");
2991
+ await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
2956
2992
  await this.query(`
2957
2993
  create table if not exists artifacts(
2958
2994
  hash text primary key,
@@ -3188,7 +3224,7 @@ export class PostgresAnonymousStore {
3188
3224
  select
3189
3225
  owner_id,
3190
3226
  count(*)::int as deploy_count,
3191
- count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
3227
+ count(*) filter (where status = 'active')::int as active_deploy_count
3192
3228
  from deploys
3193
3229
  where owner_id is not null
3194
3230
  group by owner_id
@@ -3238,7 +3274,7 @@ export class PostgresAnonymousStore {
3238
3274
  select
3239
3275
  owner_id,
3240
3276
  count(*)::int as deploy_count,
3241
- count(*) filter (where status = 'active' and expires_at > now())::int as active_deploy_count
3277
+ count(*) filter (where status = 'active')::int as active_deploy_count
3242
3278
  from deploys
3243
3279
  where owner_id is not null
3244
3280
  group by owner_id
@@ -3310,6 +3346,7 @@ export class PostgresAnonymousStore {
3310
3346
  publicRootUrl,
3311
3347
  slug,
3312
3348
  status: "active",
3349
+ updatedAt: createdAt,
3313
3350
  url
3314
3351
  };
3315
3352
 
@@ -3317,10 +3354,10 @@ export class PostgresAnonymousStore {
3317
3354
  await this.query(
3318
3355
  `
3319
3356
  insert into deploys(
3320
- id, slug, status, artifact_hash, client_bundle_hash, created_at, expires_at,
3357
+ id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
3321
3358
  claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
3322
3359
  )
3323
- values($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, '{}'::jsonb, $10, $11, $12)
3360
+ values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
3324
3361
  `,
3325
3362
  [
3326
3363
  deploy.id,
@@ -3329,6 +3366,7 @@ export class PostgresAnonymousStore {
3329
3366
  deploy.artifactHash,
3330
3367
  deploy.clientBundleHash,
3331
3368
  deploy.createdAt,
3369
+ deploy.updatedAt,
3332
3370
  deploy.expiresAt,
3333
3371
  deploy.claimTokenHash,
3334
3372
  JSON.stringify(deploy.limits),
@@ -3367,13 +3405,13 @@ export class PostgresAnonymousStore {
3367
3405
  return null;
3368
3406
  }
3369
3407
 
3370
- const createdAt = now();
3408
+ const updatedAt = now();
3371
3409
  const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
3372
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
3410
+ const expiresAt = currentDeploy.ownerId ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
3373
3411
  const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
3374
3412
  const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
3375
3413
  const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
3376
- await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
3414
+ await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt: updatedAt });
3377
3415
 
3378
3416
  const result = await this.query(
3379
3417
  `
@@ -3384,14 +3422,15 @@ export class PostgresAnonymousStore {
3384
3422
  expires_at = $4,
3385
3423
  public_root_url = $5,
3386
3424
  app_base_domain = $6,
3387
- url = $7
3425
+ url = $7,
3426
+ updated_at = $8
3388
3427
  where id = $1
3389
3428
  returning *
3390
3429
  `,
3391
- [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
3430
+ [deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, updatedAt]
3392
3431
  );
3393
3432
  if (serverEnv !== undefined) {
3394
- await this.replaceServerEnv(deployId, serverEnv, createdAt);
3433
+ await this.replaceServerEnv(deployId, serverEnv, updatedAt);
3395
3434
  }
3396
3435
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3397
3436
  }
@@ -3410,17 +3449,20 @@ export class PostgresAnonymousStore {
3410
3449
  return { deploy: currentDeploy, status: "conflict" };
3411
3450
  }
3412
3451
 
3413
- const claimedAt = currentDeploy.claimedAt ?? now();
3452
+ const updatedAt = now();
3453
+ const claimedAt = currentDeploy.claimedAt ?? updatedAt;
3414
3454
  const result = await this.query(
3415
3455
  `
3416
3456
  update deploys
3417
3457
  set claimed_at = coalesce(claimed_at, $3),
3418
3458
  owner_id = $2,
3419
- owner_json = $4::jsonb
3459
+ owner_json = $4::jsonb,
3460
+ expires_at = null,
3461
+ updated_at = $5
3420
3462
  where id = $1
3421
3463
  returning *
3422
3464
  `,
3423
- [deployId, owner.id, claimedAt, JSON.stringify(owner)]
3465
+ [deployId, owner.id, claimedAt, JSON.stringify(owner), updatedAt]
3424
3466
  );
3425
3467
  await this.rememberUser(owner);
3426
3468
  return {
@@ -3430,14 +3472,16 @@ export class PostgresAnonymousStore {
3430
3472
  }
3431
3473
 
3432
3474
  async terminateDeploy(deployId) {
3475
+ const updatedAt = now();
3433
3476
  const result = await this.query(
3434
3477
  `
3435
3478
  update deploys
3436
- set status = 'terminated'
3479
+ set status = 'terminated',
3480
+ updated_at = $2
3437
3481
  where id = $1
3438
3482
  returning *
3439
3483
  `,
3440
- [deployId]
3484
+ [deployId, updatedAt]
3441
3485
  );
3442
3486
  return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
3443
3487
  }
@@ -3454,7 +3498,7 @@ export class PostgresAnonymousStore {
3454
3498
  claimTokenHash: row.claim_token_hash,
3455
3499
  clientBundleHash: row.client_bundle_hash,
3456
3500
  createdAt: new Date(row.created_at).toISOString(),
3457
- expiresAt: new Date(row.expires_at).toISOString(),
3501
+ expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
3458
3502
  id: row.id,
3459
3503
  limits: row.limits_json,
3460
3504
  owner: row.owner_json ?? null,
@@ -3462,6 +3506,7 @@ export class PostgresAnonymousStore {
3462
3506
  publicRootUrl: row.public_root_url,
3463
3507
  slug: row.slug,
3464
3508
  status: row.status,
3509
+ updatedAt: new Date(row.updated_at).toISOString(),
3465
3510
  url: row.url
3466
3511
  };
3467
3512
  }
@@ -3808,6 +3853,7 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
3808
3853
  queries: Object.keys(artifact.server.queries ?? {}),
3809
3854
  schema: artifact.server.schema,
3810
3855
  slug: deploy.slug,
3856
+ updatedAt: deploy.updatedAt,
3811
3857
  url: deploy.url
3812
3858
  });
3813
3859
  return true;
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createServer } from "node:http";
4
4
  import { existsSync, realpathSync } from "node:fs";
5
5
  import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { clearLine, cursorTo } from "node:readline";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { promisify } from "node:util";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -105,6 +106,89 @@ function hasFlag(args, name) {
105
106
  return args.includes(name);
106
107
  }
107
108
 
109
+ function formatDevUpdateAge(updatedAt, now = new Date()) {
110
+ const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
111
+ if (elapsedSeconds < 30) {
112
+ return `${elapsedSeconds} ${elapsedSeconds === 1 ? "second" : "seconds"} ago`;
113
+ }
114
+
115
+ if (elapsedSeconds < 60) {
116
+ return "under 1 minute ago";
117
+ }
118
+
119
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
120
+ return `${elapsedMinutes} ${elapsedMinutes === 1 ? "minute" : "minutes"} ago`;
121
+ }
122
+
123
+ function formatOptionalTimestamp(value, fallback = "never") {
124
+ return value || fallback;
125
+ }
126
+
127
+ function devUpdateRefreshDelay(updatedAt, now = new Date()) {
128
+ const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
129
+ return elapsedSeconds < 30 ? 1_000 : 30_000;
130
+ }
131
+
132
+ function createDevStatusWriter({ quiet = false } = {}) {
133
+ let wroteStatusLine = false;
134
+ let lastUpdatedAt = null;
135
+ let refreshTimer = null;
136
+
137
+ function clearRefreshTimer() {
138
+ if (refreshTimer) {
139
+ clearTimeout(refreshTimer);
140
+ refreshTimer = null;
141
+ }
142
+ }
143
+
144
+ function render() {
145
+ if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
146
+ return;
147
+ }
148
+
149
+ const message = `Live updated with your changes ${formatDevUpdateAge(lastUpdatedAt)}`;
150
+ clearLine(process.stdout, 0);
151
+ cursorTo(process.stdout, 0);
152
+ process.stdout.write(message);
153
+ wroteStatusLine = true;
154
+ }
155
+
156
+ function scheduleRefresh() {
157
+ clearRefreshTimer();
158
+ if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
159
+ return;
160
+ }
161
+
162
+ refreshTimer = setTimeout(() => {
163
+ refreshTimer = null;
164
+ render();
165
+ scheduleRefresh();
166
+ }, devUpdateRefreshDelay(lastUpdatedAt));
167
+ }
168
+
169
+ return {
170
+ close() {
171
+ clearRefreshTimer();
172
+ this.finish();
173
+ },
174
+ finish() {
175
+ if (wroteStatusLine && process.stdout.isTTY) {
176
+ process.stdout.write("\n");
177
+ }
178
+ wroteStatusLine = false;
179
+ },
180
+ update(date = new Date()) {
181
+ if (quiet || !process.stdout.isTTY) {
182
+ return;
183
+ }
184
+
185
+ lastUpdatedAt = date;
186
+ render();
187
+ scheduleRefresh();
188
+ }
189
+ };
190
+ }
191
+
108
192
  function resolveCapsuleDir(value) {
109
193
  if (!value) {
110
194
  return root;
@@ -446,7 +530,8 @@ export async function startDevServer({
446
530
  let currentBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
447
531
  const defaultAuth = await readAuth();
448
532
  const stateCell = new StateCell(currentBuild.app.schema);
449
- const logs = new LogBuffer();
533
+ const devStatus = createDevStatusWriter({ quiet });
534
+ const logs = new LogBuffer({ beforeConsoleWrite: () => devStatus.finish() });
450
535
  const subscriptions = new Map();
451
536
  let fileFingerprint = sourceStore ? "" : await capsuleFileFingerprint(resolvedCapsuleDir);
452
537
  let rebuildTimer = null;
@@ -457,7 +542,7 @@ export async function startDevServer({
457
542
  const nextBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
458
543
  currentBuild = nextBuild;
459
544
  stateCell.updateSchema(nextBuild.app.schema);
460
- logs.append("info", "dev server rebuilt capsule", { capsuleDir: resolvedCapsuleDir });
545
+ devStatus.update();
461
546
  for (const client of wss.clients) {
462
547
  sendJson(client, { op: "refresh" });
463
548
  }
@@ -675,6 +760,7 @@ export async function startDevServer({
675
760
  clearTimeout(rebuildTimer);
676
761
  }
677
762
  await rebuildPromise.catch(() => {});
763
+ devStatus.close();
678
764
  for (const client of wss.clients) {
679
765
  client.close();
680
766
  }
@@ -1044,7 +1130,8 @@ async function deployCommand(args) {
1044
1130
  console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
1045
1131
  }
1046
1132
  console.log(`App: ${deployed.url}`);
1047
- console.log(`Expires: ${deployed.expiresAt}`);
1133
+ console.log(`Updated: ${formatOptionalTimestamp(deployed.updatedAt, "unknown")}`);
1134
+ console.log(`Expires: ${formatOptionalTimestamp(deployed.expiresAt)}`);
1048
1135
  if (deployed.claimUrl) {
1049
1136
  console.log(`Claim: ${deployed.claimUrl}`);
1050
1137
  }
@@ -1163,7 +1250,8 @@ async function inspectCommand(args) {
1163
1250
 
1164
1251
  console.log(`Deploy: ${manifest.deployId}`);
1165
1252
  console.log(`URL: ${manifest.url}`);
1166
- console.log(`Expires: ${manifest.expiresAt}`);
1253
+ console.log(`Updated: ${formatOptionalTimestamp(manifest.updatedAt, "unknown")}`);
1254
+ console.log(`Expires: ${formatOptionalTimestamp(manifest.expiresAt)}`);
1167
1255
  console.log(`Artifact: ${manifest.artifactHash}`);
1168
1256
  console.log(`Queries: ${manifest.queries.join(", ") || "(none)"}`);
1169
1257
  console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
package/src/client.js CHANGED
@@ -6,6 +6,7 @@ const AUTH_STORAGE_KEY = "lakebed_identity";
6
6
  const LEGACY_SHOO_STORAGE_KEY = "shoo_identity";
7
7
  const PKCE_STORAGE_KEY = "lakebed_google_pkce";
8
8
  const RETURN_TO_STORAGE_KEY = "lakebed_google_return_to";
9
+ const AUTH_RESUME_STORAGE_KEY = "lakebed_google_resume_attempt";
9
10
  const PKCE_MAX_AGE_MS = 10 * 60 * 1000;
10
11
  const encoder = new TextEncoder();
11
12
 
@@ -19,6 +20,7 @@ const pending = new Map();
19
20
  const activeSubscriptions = new Set();
20
21
  let authInitPromise = null;
21
22
  let authInitialized = false;
23
+ let authResumeStarted = false;
22
24
  let refreshRequested = false;
23
25
 
24
26
  function toGuestName(name) {
@@ -67,6 +69,18 @@ function browserStorage() {
67
69
  }
68
70
  }
69
71
 
72
+ function browserSessionStorage() {
73
+ if (typeof window === "undefined") {
74
+ return null;
75
+ }
76
+
77
+ try {
78
+ return window.sessionStorage;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
70
84
  function currentGuestName() {
71
85
  if (typeof window === "undefined") {
72
86
  return "local";
@@ -236,7 +250,11 @@ export function decodeIdentityClaims(idToken) {
236
250
  return parseJson(decodeBase64Url(parts[1]));
237
251
  }
238
252
 
239
- function readStoredIdentity() {
253
+ function isExpiredClaims(claims) {
254
+ return typeof claims?.exp === "number" && claims.exp * 1000 <= Date.now();
255
+ }
256
+
257
+ function readStoredIdentity({ allowExpired = false } = {}) {
240
258
  const storage = browserStorage();
241
259
  if (!storage) {
242
260
  return { userId: null };
@@ -260,12 +278,13 @@ function readStoredIdentity() {
260
278
 
261
279
  const token = typeof parsed.token === "string" ? parsed.token : undefined;
262
280
  const claims = decodeIdentityClaims(token);
263
- if (typeof claims?.exp === "number" && claims.exp * 1000 <= Date.now()) {
264
- clearStoredIdentity();
265
- return { userId: null };
281
+ const expired = isExpiredClaims(claims);
282
+ if (expired && !allowExpired) {
283
+ return { expired, userId: null };
266
284
  }
267
285
 
268
286
  return {
287
+ expired,
269
288
  token,
270
289
  userId: typeof parsed.userId === "string" ? parsed.userId : (typeof parsed.pairwiseSub === "string" ? parsed.pairwiseSub : null)
271
290
  };
@@ -306,6 +325,19 @@ function clearStoredIdentity() {
306
325
  }
307
326
  }
308
327
 
328
+ function clearAuthResumeAttempt() {
329
+ const storage = browserSessionStorage();
330
+ if (!storage) {
331
+ return;
332
+ }
333
+
334
+ try {
335
+ storage.removeItem(AUTH_RESUME_STORAGE_KEY);
336
+ } catch {
337
+ // Ignore storage failures; resume attempts are best effort.
338
+ }
339
+ }
340
+
309
341
  function storedAuthToken() {
310
342
  return readStoredIdentity().token ?? "";
311
343
  }
@@ -431,6 +463,7 @@ async function handleGoogleCallback() {
431
463
  throw new Error("Google sign-in token response was missing identity claims.");
432
464
  }
433
465
  persistIdentity(token.pairwise_sub, token.id_token, token.expires_in);
466
+ clearAuthResumeAttempt();
434
467
  window.sessionStorage.removeItem(PKCE_STORAGE_KEY);
435
468
 
436
469
  const localAuth = createGoogleAuthFromToken(token.id_token);
@@ -445,11 +478,78 @@ async function handleGoogleCallback() {
445
478
  return token;
446
479
  }
447
480
 
481
+ function authResumeAttemptKey(identity) {
482
+ const claims = decodeIdentityClaims(identity.token);
483
+ return [identity.userId, claims?.jti, claims?.exp].filter(Boolean).join(":") || identity.token;
484
+ }
485
+
486
+ function beginStoredGoogleSessionResume() {
487
+ if (typeof window === "undefined" || authResumeStarted) {
488
+ return false;
489
+ }
490
+
491
+ if (parseCallback()) {
492
+ return false;
493
+ }
494
+
495
+ const search = new URLSearchParams(window.location.search);
496
+ if (search.has("error") || window.location.pathname === callbackPath()) {
497
+ return false;
498
+ }
499
+
500
+ if (search.has("lakebed_guest") || search.has("guest")) {
501
+ return false;
502
+ }
503
+
504
+ const identity = readStoredIdentity({ allowExpired: true });
505
+ if (!identity.token || !identity.expired) {
506
+ return false;
507
+ }
508
+
509
+ const claims = decodeIdentityClaims(identity.token);
510
+ if (!claims?.pairwise_sub && !claims?.sub) {
511
+ clearStoredIdentity();
512
+ return false;
513
+ }
514
+
515
+ const storage = browserSessionStorage();
516
+ const attemptKey = authResumeAttemptKey(identity);
517
+ try {
518
+ if (storage?.getItem(AUTH_RESUME_STORAGE_KEY) === attemptKey) {
519
+ return false;
520
+ }
521
+ storage?.setItem(AUTH_RESUME_STORAGE_KEY, attemptKey);
522
+ } catch {
523
+ // Without sessionStorage, the page navigation below is still safe.
524
+ }
525
+
526
+ authResumeStarted = true;
527
+ auth = withAuthLoading(createGuestAuth(currentGuestName()), true);
528
+ emitAuth();
529
+
530
+ void signInWithGoogle({ returnTo: currentRoute() }).catch((error) => {
531
+ console.error("[lakebed] Google session resume failed", error);
532
+ authResumeStarted = false;
533
+ clearStoredIdentity();
534
+ clearAuthResumeAttempt();
535
+ auth = withAuthLoading(createGuestAuth(currentGuestName()), false);
536
+ emitAuth();
537
+ reconnect();
538
+ });
539
+
540
+ return true;
541
+ }
542
+
448
543
  function ensureAuthInitialized() {
449
544
  if (authInitialized) {
450
545
  return Promise.resolve();
451
546
  }
452
547
 
548
+ if (beginStoredGoogleSessionResume()) {
549
+ authInitialized = true;
550
+ return Promise.resolve();
551
+ }
552
+
453
553
  authInitPromise ??= handleGoogleCallback()
454
554
  .catch((error) => {
455
555
  console.error("[lakebed] Google sign-in failed", error);
@@ -501,6 +601,9 @@ function connect() {
501
601
  const message = JSON.parse(String(event.data));
502
602
 
503
603
  if (message.op === "auth.result") {
604
+ if (authResumeStarted) {
605
+ return;
606
+ }
504
607
  auth = withAuthLoading(message.auth, false);
505
608
  emitAuth();
506
609
  return;
@@ -592,6 +695,7 @@ export async function signInWithGoogle(options = {}) {
592
695
 
593
696
  export function signOut() {
594
697
  clearStoredIdentity();
698
+ clearAuthResumeAttempt();
595
699
  auth = withAuthLoading(createGuestAuth(currentGuestName()), true);
596
700
  emitAuth();
597
701
  reconnect();
package/src/runtime.js CHANGED
@@ -243,8 +243,9 @@ export class StateCell {
243
243
  }
244
244
 
245
245
  export class LogBuffer {
246
- constructor() {
246
+ constructor({ beforeConsoleWrite } = {}) {
247
247
  this.entries = [];
248
+ this.beforeConsoleWrite = beforeConsoleWrite;
248
249
  }
249
250
 
250
251
  append(level, message, data) {
@@ -255,6 +256,7 @@ export class LogBuffer {
255
256
  at: now()
256
257
  };
257
258
  this.entries.push(entry);
259
+ this.beforeConsoleWrite?.();
258
260
  console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[lakebed:${level}] ${message}`, data ?? "");
259
261
  }
260
262
 
package/src/version.js CHANGED
@@ -1 +1 @@
1
- export const LAKEBED_VERSION = "0.0.13";
1
+ export const LAKEBED_VERSION = "0.0.14";