lakebed 0.0.13 → 0.0.15
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 +3 -3
- package/package.json +1 -1
- package/src/anonymous-server.js +83 -40
- package/src/cli.js +145 -21
- package/src/client.js +108 -4
- package/src/runtime.js +3 -1
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ lakebed new [name] [--template todo] [--no-git]
|
|
|
122
122
|
lakebed create [name] [--template todo] [--no-git]
|
|
123
123
|
lakebed dev [capsule-dir] [--port 3000]
|
|
124
124
|
lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
125
|
-
lakebed deploy [capsule-dir] [--
|
|
125
|
+
lakebed deploy [capsule-dir] [--api <url>] [--json]
|
|
126
126
|
lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
127
127
|
lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
128
128
|
lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
|
|
@@ -167,7 +167,7 @@ LAKEBED_APP_BASE_DOMAIN=lakebed.app
|
|
|
167
167
|
|
|
168
168
|
With a verified `*.lakebed.app` custom domain on the runner, deploy responses use `https://<slug>.lakebed.app`.
|
|
169
169
|
|
|
170
|
-
Deploy responses include
|
|
170
|
+
Deploy responses include claim metadata. Configure GitHub OAuth on the runner, then run `lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
|
|
171
171
|
|
|
172
172
|
```sh
|
|
173
173
|
LAKEBED_GITHUB_CLIENT_ID=...
|
|
@@ -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
|
|
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 `lakebed claim` command. Run that command, 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
package/src/anonymous-server.js
CHANGED
|
@@ -19,6 +19,11 @@ function now() {
|
|
|
19
19
|
return new Date().toISOString();
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function anonymousDeployExpiresAt() {
|
|
23
|
+
const ttlSeconds = parseTtlSeconds();
|
|
24
|
+
return new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
function dayWindowStart() {
|
|
23
28
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
24
29
|
}
|
|
@@ -190,11 +195,16 @@ function responseForDeploy({ deploy, token }) {
|
|
|
190
195
|
expiresAt: deploy.expiresAt,
|
|
191
196
|
inspect: inspectUrls(deploy.url),
|
|
192
197
|
limits: deploy.limits,
|
|
198
|
+
updatedAt: deploy.updatedAt,
|
|
193
199
|
url: deploy.url
|
|
194
200
|
};
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
function isExpired(deploy) {
|
|
204
|
+
if (deploy?.ownerId) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
198
208
|
return Boolean(deploy.expiresAt) && Date.parse(deploy.expiresAt) <= Date.now();
|
|
199
209
|
}
|
|
200
210
|
|
|
@@ -705,6 +715,7 @@ function adminDeploySummary({ artifact, artifactBytes = 0, deploy, logBytes = 0,
|
|
|
705
715
|
stateRows,
|
|
706
716
|
status: isExpired(deploy) ? "expired" : deploy.status,
|
|
707
717
|
tableCount: Object.keys(artifact?.server?.schema ?? {}).length,
|
|
718
|
+
updatedAt: deploy.updatedAt,
|
|
708
719
|
url: deploy.url,
|
|
709
720
|
...usageCounts(usage)
|
|
710
721
|
};
|
|
@@ -1286,6 +1297,7 @@ function adminHtml() {
|
|
|
1286
1297
|
<th>User</th>
|
|
1287
1298
|
<th>Status</th>
|
|
1288
1299
|
<th>Created</th>
|
|
1300
|
+
<th>Updated</th>
|
|
1289
1301
|
<th>Expires</th>
|
|
1290
1302
|
<th>Usage</th>
|
|
1291
1303
|
<th>Storage</th>
|
|
@@ -1389,6 +1401,7 @@ function adminHtml() {
|
|
|
1389
1401
|
<th>User</th>
|
|
1390
1402
|
<th>Status</th>
|
|
1391
1403
|
<th>Created</th>
|
|
1404
|
+
<th>Updated</th>
|
|
1392
1405
|
<th>Expires</th>
|
|
1393
1406
|
<th>Usage</th>
|
|
1394
1407
|
<th>Storage</th>
|
|
@@ -1464,6 +1477,10 @@ function adminHtml() {
|
|
|
1464
1477
|
}).format(new Date(value));
|
|
1465
1478
|
}
|
|
1466
1479
|
|
|
1480
|
+
function formatExpiry(value) {
|
|
1481
|
+
return value ? formatTime(value) : "never";
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1467
1484
|
function plural(count, label) {
|
|
1468
1485
|
return formatNumber(count) + " " + label + (count === 1 ? "" : "s");
|
|
1469
1486
|
}
|
|
@@ -1715,7 +1732,7 @@ function adminHtml() {
|
|
|
1715
1732
|
if (!deploys.length) {
|
|
1716
1733
|
const tr = document.createElement("tr");
|
|
1717
1734
|
const td = textCell(emptyText, "mono");
|
|
1718
|
-
td.colSpan =
|
|
1735
|
+
td.colSpan = 10;
|
|
1719
1736
|
tr.appendChild(td);
|
|
1720
1737
|
tbody.appendChild(tr);
|
|
1721
1738
|
return;
|
|
@@ -1727,7 +1744,8 @@ function adminHtml() {
|
|
|
1727
1744
|
tr.appendChild(deployUserCell(deploy));
|
|
1728
1745
|
tr.appendChild(statusCell(deploy));
|
|
1729
1746
|
tr.appendChild(textCell(formatTime(deploy.createdAt), "mono"));
|
|
1730
|
-
tr.appendChild(textCell(formatTime(deploy.
|
|
1747
|
+
tr.appendChild(textCell(formatTime(deploy.updatedAt), "mono"));
|
|
1748
|
+
tr.appendChild(textCell(formatExpiry(deploy.expiresAt), "mono"));
|
|
1731
1749
|
tr.appendChild(usageCell(deploy));
|
|
1732
1750
|
tr.appendChild(storageCell(deploy));
|
|
1733
1751
|
tr.appendChild(textCell(formatNumber(deploy.connections), "mono"));
|
|
@@ -2207,6 +2225,14 @@ function escapeHtml(value) {
|
|
|
2207
2225
|
.replace(/"/g, """);
|
|
2208
2226
|
}
|
|
2209
2227
|
|
|
2228
|
+
function formatHtmlTime(value) {
|
|
2229
|
+
return value ? new Date(value).toLocaleString() : "unknown";
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function formatHtmlExpiry(value) {
|
|
2233
|
+
return value ? formatHtmlTime(value) : "never";
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2210
2236
|
function developerDeploySummary({ artifact, deploy, usage }) {
|
|
2211
2237
|
return {
|
|
2212
2238
|
artifactHash: deploy.artifactHash,
|
|
@@ -2221,6 +2247,7 @@ function developerDeploySummary({ artifact, deploy, usage }) {
|
|
|
2221
2247
|
ownerId: deploy.ownerId,
|
|
2222
2248
|
slug: deploy.slug,
|
|
2223
2249
|
status: isExpired(deploy) ? "expired" : deploy.status,
|
|
2250
|
+
updatedAt: deploy.updatedAt,
|
|
2224
2251
|
url: deploy.url,
|
|
2225
2252
|
usage: usageCounts(usage)
|
|
2226
2253
|
};
|
|
@@ -2233,8 +2260,9 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2233
2260
|
(deploy) => `<tr>
|
|
2234
2261
|
<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
2262
|
<td><span class="pill ${escapeHtml(deploy.status)}">${escapeHtml(deploy.status)}</span></td>
|
|
2236
|
-
<td>${escapeHtml(
|
|
2237
|
-
<td>${escapeHtml(
|
|
2263
|
+
<td>${escapeHtml(formatHtmlTime(deploy.createdAt))}</td>
|
|
2264
|
+
<td>${escapeHtml(formatHtmlTime(deploy.updatedAt))}</td>
|
|
2265
|
+
<td>${escapeHtml(formatHtmlExpiry(deploy.expiresAt))}</td>
|
|
2238
2266
|
<td>${escapeHtml(deploy.usage.requestsToday)} / ${escapeHtml(deploy.limits.requestsPerDay)}</td>
|
|
2239
2267
|
<td>${escapeHtml(deploy.usage.mutationsToday)} / ${escapeHtml(deploy.limits.mutationsPerDay)}</td>
|
|
2240
2268
|
</tr>`
|
|
@@ -2434,6 +2462,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2434
2462
|
<th>Deploy</th>
|
|
2435
2463
|
<th>Status</th>
|
|
2436
2464
|
<th>Created</th>
|
|
2465
|
+
<th>Updated</th>
|
|
2437
2466
|
<th>Expires</th>
|
|
2438
2467
|
<th>Requests</th>
|
|
2439
2468
|
<th>Mutations</th>
|
|
@@ -2585,7 +2614,7 @@ export class MemoryAnonymousStore {
|
|
|
2585
2614
|
});
|
|
2586
2615
|
}
|
|
2587
2616
|
|
|
2588
|
-
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl,
|
|
2617
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
2589
2618
|
const deployId = createDeployId();
|
|
2590
2619
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
2591
2620
|
let slug = createSlug();
|
|
@@ -2595,8 +2624,7 @@ export class MemoryAnonymousStore {
|
|
|
2595
2624
|
|
|
2596
2625
|
const token = createClaimToken();
|
|
2597
2626
|
const createdAt = now();
|
|
2598
|
-
const
|
|
2599
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
2627
|
+
const expiresAt = anonymousDeployExpiresAt();
|
|
2600
2628
|
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
2601
2629
|
|
|
2602
2630
|
this.storeArtifact({
|
|
@@ -2622,6 +2650,7 @@ export class MemoryAnonymousStore {
|
|
|
2622
2650
|
publicRootUrl,
|
|
2623
2651
|
slug,
|
|
2624
2652
|
status: "active",
|
|
2653
|
+
updatedAt: createdAt,
|
|
2625
2654
|
url
|
|
2626
2655
|
};
|
|
2627
2656
|
this.deploys.set(deployId, deploy);
|
|
@@ -2640,7 +2669,6 @@ export class MemoryAnonymousStore {
|
|
|
2640
2669
|
clientBundleHash,
|
|
2641
2670
|
deployId,
|
|
2642
2671
|
publicRootUrl,
|
|
2643
|
-
requestedTtlSeconds,
|
|
2644
2672
|
serverEnv
|
|
2645
2673
|
}) {
|
|
2646
2674
|
const currentDeploy = await this.getStoredDeployById(deployId);
|
|
@@ -2648,8 +2676,7 @@ export class MemoryAnonymousStore {
|
|
|
2648
2676
|
return null;
|
|
2649
2677
|
}
|
|
2650
2678
|
|
|
2651
|
-
const
|
|
2652
|
-
const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
|
|
2679
|
+
const updatedAt = now();
|
|
2653
2680
|
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
2654
2681
|
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
2655
2682
|
const deploy = {
|
|
@@ -2657,10 +2684,11 @@ export class MemoryAnonymousStore {
|
|
|
2657
2684
|
appBaseDomain: nextAppBaseDomain,
|
|
2658
2685
|
artifactHash,
|
|
2659
2686
|
clientBundleHash,
|
|
2660
|
-
expiresAt:
|
|
2687
|
+
expiresAt: currentDeploy.ownerId ? null : anonymousDeployExpiresAt(),
|
|
2661
2688
|
publicRootUrl: nextPublicRootUrl,
|
|
2662
2689
|
url: appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug }),
|
|
2663
|
-
status: "active"
|
|
2690
|
+
status: "active",
|
|
2691
|
+
updatedAt
|
|
2664
2692
|
};
|
|
2665
2693
|
|
|
2666
2694
|
this.storeArtifact({
|
|
@@ -2668,7 +2696,7 @@ export class MemoryAnonymousStore {
|
|
|
2668
2696
|
artifactHash,
|
|
2669
2697
|
clientBundleBase64,
|
|
2670
2698
|
clientBundleHash,
|
|
2671
|
-
createdAt
|
|
2699
|
+
createdAt: updatedAt
|
|
2672
2700
|
});
|
|
2673
2701
|
this.deploys.set(deployId, deploy);
|
|
2674
2702
|
if (serverEnv !== undefined) {
|
|
@@ -2691,11 +2719,14 @@ export class MemoryAnonymousStore {
|
|
|
2691
2719
|
return { deploy: currentDeploy, status: "conflict" };
|
|
2692
2720
|
}
|
|
2693
2721
|
|
|
2722
|
+
const updatedAt = now();
|
|
2694
2723
|
const deploy = {
|
|
2695
2724
|
...currentDeploy,
|
|
2696
|
-
claimedAt: currentDeploy.claimedAt ??
|
|
2725
|
+
claimedAt: currentDeploy.claimedAt ?? updatedAt,
|
|
2726
|
+
expiresAt: null,
|
|
2697
2727
|
owner,
|
|
2698
|
-
ownerId: owner.id
|
|
2728
|
+
ownerId: owner.id,
|
|
2729
|
+
updatedAt
|
|
2699
2730
|
};
|
|
2700
2731
|
await this.rememberUser(owner);
|
|
2701
2732
|
this.deploys.set(deployId, deploy);
|
|
@@ -2710,7 +2741,8 @@ export class MemoryAnonymousStore {
|
|
|
2710
2741
|
|
|
2711
2742
|
const deploy = {
|
|
2712
2743
|
...currentDeploy,
|
|
2713
|
-
status: "terminated"
|
|
2744
|
+
status: "terminated",
|
|
2745
|
+
updatedAt: now()
|
|
2714
2746
|
};
|
|
2715
2747
|
this.deploys.set(deployId, deploy);
|
|
2716
2748
|
return this.deployWithUserLimitOverrides(deploy);
|
|
@@ -2938,7 +2970,8 @@ export class PostgresAnonymousStore {
|
|
|
2938
2970
|
artifact_hash text not null,
|
|
2939
2971
|
client_bundle_hash text not null,
|
|
2940
2972
|
created_at timestamptz not null,
|
|
2941
|
-
|
|
2973
|
+
updated_at timestamptz not null,
|
|
2974
|
+
expires_at timestamptz,
|
|
2942
2975
|
claimed_at timestamptz,
|
|
2943
2976
|
owner_id text,
|
|
2944
2977
|
owner_json jsonb,
|
|
@@ -2953,6 +2986,11 @@ export class PostgresAnonymousStore {
|
|
|
2953
2986
|
await this.query("alter table deploys add column if not exists claimed_at timestamptz");
|
|
2954
2987
|
await this.query("alter table deploys add column if not exists owner_id text");
|
|
2955
2988
|
await this.query("alter table deploys add column if not exists owner_json jsonb");
|
|
2989
|
+
await this.query("alter table deploys add column if not exists updated_at timestamptz");
|
|
2990
|
+
await this.query("update deploys set updated_at = created_at where updated_at is null");
|
|
2991
|
+
await this.query("alter table deploys alter column updated_at set not null");
|
|
2992
|
+
await this.query("alter table deploys alter column expires_at drop not null");
|
|
2993
|
+
await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
|
|
2956
2994
|
await this.query(`
|
|
2957
2995
|
create table if not exists artifacts(
|
|
2958
2996
|
hash text primary key,
|
|
@@ -3188,7 +3226,7 @@ export class PostgresAnonymousStore {
|
|
|
3188
3226
|
select
|
|
3189
3227
|
owner_id,
|
|
3190
3228
|
count(*)::int as deploy_count,
|
|
3191
|
-
count(*) filter (where status = 'active'
|
|
3229
|
+
count(*) filter (where status = 'active')::int as active_deploy_count
|
|
3192
3230
|
from deploys
|
|
3193
3231
|
where owner_id is not null
|
|
3194
3232
|
group by owner_id
|
|
@@ -3238,7 +3276,7 @@ export class PostgresAnonymousStore {
|
|
|
3238
3276
|
select
|
|
3239
3277
|
owner_id,
|
|
3240
3278
|
count(*)::int as deploy_count,
|
|
3241
|
-
count(*) filter (where status = 'active'
|
|
3279
|
+
count(*) filter (where status = 'active')::int as active_deploy_count
|
|
3242
3280
|
from deploys
|
|
3243
3281
|
where owner_id is not null
|
|
3244
3282
|
group by owner_id
|
|
@@ -3282,11 +3320,10 @@ export class PostgresAnonymousStore {
|
|
|
3282
3320
|
);
|
|
3283
3321
|
}
|
|
3284
3322
|
|
|
3285
|
-
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl,
|
|
3323
|
+
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
3286
3324
|
const createdAt = now();
|
|
3287
3325
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
3288
|
-
const
|
|
3289
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
3326
|
+
const expiresAt = anonymousDeployExpiresAt();
|
|
3290
3327
|
const token = createClaimToken();
|
|
3291
3328
|
const deployId = createDeployId();
|
|
3292
3329
|
|
|
@@ -3310,6 +3347,7 @@ export class PostgresAnonymousStore {
|
|
|
3310
3347
|
publicRootUrl,
|
|
3311
3348
|
slug,
|
|
3312
3349
|
status: "active",
|
|
3350
|
+
updatedAt: createdAt,
|
|
3313
3351
|
url
|
|
3314
3352
|
};
|
|
3315
3353
|
|
|
@@ -3317,10 +3355,10 @@ export class PostgresAnonymousStore {
|
|
|
3317
3355
|
await this.query(
|
|
3318
3356
|
`
|
|
3319
3357
|
insert into deploys(
|
|
3320
|
-
id, slug, status, artifact_hash, client_bundle_hash, created_at, expires_at,
|
|
3358
|
+
id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
|
|
3321
3359
|
claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
|
|
3322
3360
|
)
|
|
3323
|
-
values($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, '{}'::jsonb, $
|
|
3361
|
+
values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
|
|
3324
3362
|
`,
|
|
3325
3363
|
[
|
|
3326
3364
|
deploy.id,
|
|
@@ -3329,6 +3367,7 @@ export class PostgresAnonymousStore {
|
|
|
3329
3367
|
deploy.artifactHash,
|
|
3330
3368
|
deploy.clientBundleHash,
|
|
3331
3369
|
deploy.createdAt,
|
|
3370
|
+
deploy.updatedAt,
|
|
3332
3371
|
deploy.expiresAt,
|
|
3333
3372
|
deploy.claimTokenHash,
|
|
3334
3373
|
JSON.stringify(deploy.limits),
|
|
@@ -3359,7 +3398,6 @@ export class PostgresAnonymousStore {
|
|
|
3359
3398
|
clientBundleHash,
|
|
3360
3399
|
deployId,
|
|
3361
3400
|
publicRootUrl,
|
|
3362
|
-
requestedTtlSeconds,
|
|
3363
3401
|
serverEnv
|
|
3364
3402
|
}) {
|
|
3365
3403
|
const currentDeploy = await this.getBaseDeployById(deployId);
|
|
@@ -3367,13 +3405,12 @@ export class PostgresAnonymousStore {
|
|
|
3367
3405
|
return null;
|
|
3368
3406
|
}
|
|
3369
3407
|
|
|
3370
|
-
const
|
|
3371
|
-
const
|
|
3372
|
-
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
3408
|
+
const updatedAt = now();
|
|
3409
|
+
const expiresAt = currentDeploy.ownerId ? null : anonymousDeployExpiresAt();
|
|
3373
3410
|
const nextAppBaseDomain = normalizeAppBaseDomain(appBaseDomain ?? currentDeploy.appBaseDomain);
|
|
3374
3411
|
const nextPublicRootUrl = publicRootUrl ?? currentDeploy.publicRootUrl;
|
|
3375
3412
|
const url = appUrlForSlug({ appBaseDomain: nextAppBaseDomain, publicRootUrl: nextPublicRootUrl, slug: currentDeploy.slug });
|
|
3376
|
-
await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
|
|
3413
|
+
await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt: updatedAt });
|
|
3377
3414
|
|
|
3378
3415
|
const result = await this.query(
|
|
3379
3416
|
`
|
|
@@ -3384,14 +3421,15 @@ export class PostgresAnonymousStore {
|
|
|
3384
3421
|
expires_at = $4,
|
|
3385
3422
|
public_root_url = $5,
|
|
3386
3423
|
app_base_domain = $6,
|
|
3387
|
-
url = $7
|
|
3424
|
+
url = $7,
|
|
3425
|
+
updated_at = $8
|
|
3388
3426
|
where id = $1
|
|
3389
3427
|
returning *
|
|
3390
3428
|
`,
|
|
3391
|
-
[deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url]
|
|
3429
|
+
[deployId, artifactHash, clientBundleHash, expiresAt, nextPublicRootUrl, nextAppBaseDomain || null, url, updatedAt]
|
|
3392
3430
|
);
|
|
3393
3431
|
if (serverEnv !== undefined) {
|
|
3394
|
-
await this.replaceServerEnv(deployId, serverEnv,
|
|
3432
|
+
await this.replaceServerEnv(deployId, serverEnv, updatedAt);
|
|
3395
3433
|
}
|
|
3396
3434
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3397
3435
|
}
|
|
@@ -3410,17 +3448,20 @@ export class PostgresAnonymousStore {
|
|
|
3410
3448
|
return { deploy: currentDeploy, status: "conflict" };
|
|
3411
3449
|
}
|
|
3412
3450
|
|
|
3413
|
-
const
|
|
3451
|
+
const updatedAt = now();
|
|
3452
|
+
const claimedAt = currentDeploy.claimedAt ?? updatedAt;
|
|
3414
3453
|
const result = await this.query(
|
|
3415
3454
|
`
|
|
3416
3455
|
update deploys
|
|
3417
3456
|
set claimed_at = coalesce(claimed_at, $3),
|
|
3418
3457
|
owner_id = $2,
|
|
3419
|
-
owner_json = $4::jsonb
|
|
3458
|
+
owner_json = $4::jsonb,
|
|
3459
|
+
expires_at = null,
|
|
3460
|
+
updated_at = $5
|
|
3420
3461
|
where id = $1
|
|
3421
3462
|
returning *
|
|
3422
3463
|
`,
|
|
3423
|
-
[deployId, owner.id, claimedAt, JSON.stringify(owner)]
|
|
3464
|
+
[deployId, owner.id, claimedAt, JSON.stringify(owner), updatedAt]
|
|
3424
3465
|
);
|
|
3425
3466
|
await this.rememberUser(owner);
|
|
3426
3467
|
return {
|
|
@@ -3430,14 +3471,16 @@ export class PostgresAnonymousStore {
|
|
|
3430
3471
|
}
|
|
3431
3472
|
|
|
3432
3473
|
async terminateDeploy(deployId) {
|
|
3474
|
+
const updatedAt = now();
|
|
3433
3475
|
const result = await this.query(
|
|
3434
3476
|
`
|
|
3435
3477
|
update deploys
|
|
3436
|
-
set status = 'terminated'
|
|
3478
|
+
set status = 'terminated',
|
|
3479
|
+
updated_at = $2
|
|
3437
3480
|
where id = $1
|
|
3438
3481
|
returning *
|
|
3439
3482
|
`,
|
|
3440
|
-
[deployId]
|
|
3483
|
+
[deployId, updatedAt]
|
|
3441
3484
|
);
|
|
3442
3485
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3443
3486
|
}
|
|
@@ -3454,7 +3497,7 @@ export class PostgresAnonymousStore {
|
|
|
3454
3497
|
claimTokenHash: row.claim_token_hash,
|
|
3455
3498
|
clientBundleHash: row.client_bundle_hash,
|
|
3456
3499
|
createdAt: new Date(row.created_at).toISOString(),
|
|
3457
|
-
expiresAt: new Date(row.expires_at).toISOString(),
|
|
3500
|
+
expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
|
|
3458
3501
|
id: row.id,
|
|
3459
3502
|
limits: row.limits_json,
|
|
3460
3503
|
owner: row.owner_json ?? null,
|
|
@@ -3462,6 +3505,7 @@ export class PostgresAnonymousStore {
|
|
|
3462
3505
|
publicRootUrl: row.public_root_url,
|
|
3463
3506
|
slug: row.slug,
|
|
3464
3507
|
status: row.status,
|
|
3508
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
3465
3509
|
url: row.url
|
|
3466
3510
|
};
|
|
3467
3511
|
}
|
|
@@ -3808,6 +3852,7 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
|
|
|
3808
3852
|
queries: Object.keys(artifact.server.queries ?? {}),
|
|
3809
3853
|
schema: artifact.server.schema,
|
|
3810
3854
|
slug: deploy.slug,
|
|
3855
|
+
updatedAt: deploy.updatedAt,
|
|
3811
3856
|
url: deploy.url
|
|
3812
3857
|
});
|
|
3813
3858
|
return true;
|
|
@@ -4347,7 +4392,6 @@ export async function startAnonymousServer({
|
|
|
4347
4392
|
clientBundleBase64: payload.clientBundleBase64,
|
|
4348
4393
|
clientBundleHash: payload.clientBundleHash,
|
|
4349
4394
|
publicRootUrl: resolvedPublicRootUrl,
|
|
4350
|
-
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
|
|
4351
4395
|
serverEnv: payload.serverEnv
|
|
4352
4396
|
});
|
|
4353
4397
|
await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy created", { artifactHash: deploy.artifactHash });
|
|
@@ -4382,7 +4426,6 @@ export async function startAnonymousServer({
|
|
|
4382
4426
|
clientBundleHash: payload.clientBundleHash,
|
|
4383
4427
|
deployId,
|
|
4384
4428
|
publicRootUrl: resolvedPublicRootUrl,
|
|
4385
|
-
requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined,
|
|
4386
4429
|
serverEnv: payload.serverEnv
|
|
4387
4430
|
});
|
|
4388
4431
|
if (!deploy) {
|
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";
|
|
@@ -16,7 +17,6 @@ import {
|
|
|
16
17
|
SERVER_ENV_FILE,
|
|
17
18
|
createAnonymousArtifact,
|
|
18
19
|
createClaimedArtifact,
|
|
19
|
-
parseTtlSeconds,
|
|
20
20
|
stableStringify,
|
|
21
21
|
validateServerEnvValues
|
|
22
22
|
} from "./anonymous.js";
|
|
@@ -40,7 +40,7 @@ Usage:
|
|
|
40
40
|
lakebed create [name] [--template todo] [--no-git]
|
|
41
41
|
lakebed dev [capsule-dir] [--port 3000]
|
|
42
42
|
lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
43
|
-
lakebed deploy [capsule-dir] [--
|
|
43
|
+
lakebed deploy [capsule-dir] [--api <url>] [--json]
|
|
44
44
|
lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
45
45
|
lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
46
46
|
lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
|
|
@@ -71,8 +71,7 @@ const optionsWithValues = new Set([
|
|
|
71
71
|
"--port",
|
|
72
72
|
"--public-root-url",
|
|
73
73
|
"--target",
|
|
74
|
-
"--template"
|
|
75
|
-
"--ttl"
|
|
74
|
+
"--template"
|
|
76
75
|
]);
|
|
77
76
|
|
|
78
77
|
function positionals(args) {
|
|
@@ -105,6 +104,89 @@ function hasFlag(args, name) {
|
|
|
105
104
|
return args.includes(name);
|
|
106
105
|
}
|
|
107
106
|
|
|
107
|
+
function formatDevUpdateAge(updatedAt, now = new Date()) {
|
|
108
|
+
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
|
|
109
|
+
if (elapsedSeconds < 30) {
|
|
110
|
+
return `${elapsedSeconds} ${elapsedSeconds === 1 ? "second" : "seconds"} ago`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (elapsedSeconds < 60) {
|
|
114
|
+
return "under 1 minute ago";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
|
118
|
+
return `${elapsedMinutes} ${elapsedMinutes === 1 ? "minute" : "minutes"} ago`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatOptionalTimestamp(value, fallback = "never") {
|
|
122
|
+
return value || fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function devUpdateRefreshDelay(updatedAt, now = new Date()) {
|
|
126
|
+
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - updatedAt.getTime()) / 1000));
|
|
127
|
+
return elapsedSeconds < 30 ? 1_000 : 30_000;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createDevStatusWriter({ quiet = false } = {}) {
|
|
131
|
+
let wroteStatusLine = false;
|
|
132
|
+
let lastUpdatedAt = null;
|
|
133
|
+
let refreshTimer = null;
|
|
134
|
+
|
|
135
|
+
function clearRefreshTimer() {
|
|
136
|
+
if (refreshTimer) {
|
|
137
|
+
clearTimeout(refreshTimer);
|
|
138
|
+
refreshTimer = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function render() {
|
|
143
|
+
if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const message = `Live updated with your changes ${formatDevUpdateAge(lastUpdatedAt)}`;
|
|
148
|
+
clearLine(process.stdout, 0);
|
|
149
|
+
cursorTo(process.stdout, 0);
|
|
150
|
+
process.stdout.write(message);
|
|
151
|
+
wroteStatusLine = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scheduleRefresh() {
|
|
155
|
+
clearRefreshTimer();
|
|
156
|
+
if (quiet || !process.stdout.isTTY || !lastUpdatedAt) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
refreshTimer = setTimeout(() => {
|
|
161
|
+
refreshTimer = null;
|
|
162
|
+
render();
|
|
163
|
+
scheduleRefresh();
|
|
164
|
+
}, devUpdateRefreshDelay(lastUpdatedAt));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
close() {
|
|
169
|
+
clearRefreshTimer();
|
|
170
|
+
this.finish();
|
|
171
|
+
},
|
|
172
|
+
finish() {
|
|
173
|
+
if (wroteStatusLine && process.stdout.isTTY) {
|
|
174
|
+
process.stdout.write("\n");
|
|
175
|
+
}
|
|
176
|
+
wroteStatusLine = false;
|
|
177
|
+
},
|
|
178
|
+
update(date = new Date()) {
|
|
179
|
+
if (quiet || !process.stdout.isTTY) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lastUpdatedAt = date;
|
|
184
|
+
render();
|
|
185
|
+
scheduleRefresh();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
108
190
|
function resolveCapsuleDir(value) {
|
|
109
191
|
if (!value) {
|
|
110
192
|
return root;
|
|
@@ -446,7 +528,8 @@ export async function startDevServer({
|
|
|
446
528
|
let currentBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
|
|
447
529
|
const defaultAuth = await readAuth();
|
|
448
530
|
const stateCell = new StateCell(currentBuild.app.schema);
|
|
449
|
-
const
|
|
531
|
+
const devStatus = createDevStatusWriter({ quiet });
|
|
532
|
+
const logs = new LogBuffer({ beforeConsoleWrite: () => devStatus.finish() });
|
|
450
533
|
const subscriptions = new Map();
|
|
451
534
|
let fileFingerprint = sourceStore ? "" : await capsuleFileFingerprint(resolvedCapsuleDir);
|
|
452
535
|
let rebuildTimer = null;
|
|
@@ -457,7 +540,7 @@ export async function startDevServer({
|
|
|
457
540
|
const nextBuild = await buildCapsule({ capsuleDir: resolvedCapsuleDir, sourceStore, capsuleId });
|
|
458
541
|
currentBuild = nextBuild;
|
|
459
542
|
stateCell.updateSchema(nextBuild.app.schema);
|
|
460
|
-
|
|
543
|
+
devStatus.update();
|
|
461
544
|
for (const client of wss.clients) {
|
|
462
545
|
sendJson(client, { op: "refresh" });
|
|
463
546
|
}
|
|
@@ -675,6 +758,7 @@ export async function startDevServer({
|
|
|
675
758
|
clearTimeout(rebuildTimer);
|
|
676
759
|
}
|
|
677
760
|
await rebuildPromise.catch(() => {});
|
|
761
|
+
devStatus.close();
|
|
678
762
|
for (const client of wss.clients) {
|
|
679
763
|
client.close();
|
|
680
764
|
}
|
|
@@ -862,12 +946,39 @@ function claimUrlFromDeployMetadata(metadata) {
|
|
|
862
946
|
return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
|
|
863
947
|
}
|
|
864
948
|
|
|
865
|
-
function
|
|
949
|
+
function claimCommandText({ api, capsuleArg }) {
|
|
950
|
+
const parts = ["lakebed", "claim"];
|
|
951
|
+
if (capsuleArg) {
|
|
952
|
+
parts.push(capsuleArg);
|
|
953
|
+
}
|
|
954
|
+
if (api !== defaultDeployApiUrl) {
|
|
955
|
+
parts.push("--api", api);
|
|
956
|
+
}
|
|
957
|
+
return parts.map(shellQuote).join(" ");
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function browserOpenInvocation(url) {
|
|
961
|
+
if (process.platform === "darwin") {
|
|
962
|
+
return { command: "open", args: [url] };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (process.platform === "win32") {
|
|
966
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return { command: "xdg-open", args: [url] };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function openUrlInBrowser(url) {
|
|
973
|
+
const invocation = browserOpenInvocation(url);
|
|
974
|
+
await execFileAsync(invocation.command, invocation.args, { windowsHide: true });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function deployRequestBody(envelope, { serverEnv } = {}) {
|
|
866
978
|
const body = {
|
|
867
979
|
artifact: envelope.artifact,
|
|
868
980
|
clientBundle: envelope.clientBundle,
|
|
869
|
-
clientVersion: LAKEBED_VERSION
|
|
870
|
-
requestedTtlSeconds: ttl
|
|
981
|
+
clientVersion: LAKEBED_VERSION
|
|
871
982
|
};
|
|
872
983
|
if (serverEnv !== undefined) {
|
|
873
984
|
body.serverEnv = {
|
|
@@ -926,6 +1037,10 @@ async function readResponseJson(response) {
|
|
|
926
1037
|
}
|
|
927
1038
|
|
|
928
1039
|
async function deployCommand(args) {
|
|
1040
|
+
if (args.some((arg) => arg === "--ttl" || arg.startsWith("--ttl="))) {
|
|
1041
|
+
throw new Error("lakebed deploy no longer accepts --ttl. Deploy expiry is set by the server.");
|
|
1042
|
+
}
|
|
1043
|
+
|
|
929
1044
|
const [capsuleArg] = positionals(args);
|
|
930
1045
|
const capsuleDir = resolveCapsuleDir(capsuleArg);
|
|
931
1046
|
const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
|
|
@@ -933,7 +1048,6 @@ async function deployCommand(args) {
|
|
|
933
1048
|
const serverEnv = await readCapsuleServerEnv(sourceStore);
|
|
934
1049
|
const serverEnvKeys = Object.keys(serverEnv).sort();
|
|
935
1050
|
const hasServerEnvValues = serverEnvKeys.length > 0;
|
|
936
|
-
const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
|
|
937
1051
|
const api = deployApiUrl(args);
|
|
938
1052
|
const metadata = await readDeployMetadata(capsuleDir);
|
|
939
1053
|
const canUpdate =
|
|
@@ -985,7 +1099,7 @@ async function deployCommand(args) {
|
|
|
985
1099
|
|
|
986
1100
|
if (canUpdate) {
|
|
987
1101
|
response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
|
|
988
|
-
body: deployRequestBody(envelope,
|
|
1102
|
+
body: deployRequestBody(envelope, { serverEnv: serverEnvForUpdate }),
|
|
989
1103
|
headers: {
|
|
990
1104
|
"Authorization": `Bearer ${metadata.claimToken}`,
|
|
991
1105
|
"Content-Type": "application/json"
|
|
@@ -1006,7 +1120,7 @@ async function deployCommand(args) {
|
|
|
1006
1120
|
}
|
|
1007
1121
|
|
|
1008
1122
|
response ??= await fetch(`${api}/v1/anonymous-deploys`, {
|
|
1009
|
-
body: deployRequestBody(envelope
|
|
1123
|
+
body: deployRequestBody(envelope),
|
|
1010
1124
|
headers: {
|
|
1011
1125
|
"Content-Type": "application/json"
|
|
1012
1126
|
},
|
|
@@ -1044,9 +1158,10 @@ async function deployCommand(args) {
|
|
|
1044
1158
|
console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
|
|
1045
1159
|
}
|
|
1046
1160
|
console.log(`App: ${deployed.url}`);
|
|
1047
|
-
console.log(`
|
|
1161
|
+
console.log(`Updated: ${formatOptionalTimestamp(deployed.updatedAt, "unknown")}`);
|
|
1162
|
+
console.log(`Expires: ${formatOptionalTimestamp(deployed.expiresAt)}`);
|
|
1048
1163
|
if (deployed.claimUrl) {
|
|
1049
|
-
console.log(`Claim: ${
|
|
1164
|
+
console.log(`Claim: ${claimCommandText({ api, capsuleArg })}`);
|
|
1050
1165
|
}
|
|
1051
1166
|
console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
|
|
1052
1167
|
console.log("\nLimits:");
|
|
@@ -1063,7 +1178,7 @@ async function deployCommand(args) {
|
|
|
1063
1178
|
}
|
|
1064
1179
|
if (envelope.claimRequired) {
|
|
1065
1180
|
console.log("\nThis app needs a claimed deploy before server-side fetch or server env can run.");
|
|
1066
|
-
console.log(
|
|
1181
|
+
console.log(`Run ${claimCommandText({ api, capsuleArg })}, then run lakebed deploy again.`);
|
|
1067
1182
|
}
|
|
1068
1183
|
}
|
|
1069
1184
|
|
|
@@ -1116,8 +1231,15 @@ async function claimCommand(args) {
|
|
|
1116
1231
|
return;
|
|
1117
1232
|
}
|
|
1118
1233
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1234
|
+
try {
|
|
1235
|
+
await openUrlInBrowser(claimUrl);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
`Unable to open the claim page in your browser: ${error instanceof Error ? error.message : String(error)}\n\nRun ${claimCommandText({ api, capsuleArg })} --json to read the claim URL.`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
console.log(`Opened claim page for deploy ${result.deployId} in your browser.`);
|
|
1121
1243
|
}
|
|
1122
1244
|
|
|
1123
1245
|
async function anonymousServerCommand(args) {
|
|
@@ -1163,7 +1285,8 @@ async function inspectCommand(args) {
|
|
|
1163
1285
|
|
|
1164
1286
|
console.log(`Deploy: ${manifest.deployId}`);
|
|
1165
1287
|
console.log(`URL: ${manifest.url}`);
|
|
1166
|
-
console.log(`
|
|
1288
|
+
console.log(`Updated: ${formatOptionalTimestamp(manifest.updatedAt, "unknown")}`);
|
|
1289
|
+
console.log(`Expires: ${formatOptionalTimestamp(manifest.expiresAt)}`);
|
|
1167
1290
|
console.log(`Artifact: ${manifest.artifactHash}`);
|
|
1168
1291
|
console.log(`Queries: ${manifest.queries.join(", ") || "(none)"}`);
|
|
1169
1292
|
console.log(`Mutations: ${manifest.mutations.join(", ") || "(none)"}`);
|
|
@@ -1477,11 +1600,12 @@ async function isInsideGitWorkTree(cwd) {
|
|
|
1477
1600
|
}
|
|
1478
1601
|
|
|
1479
1602
|
function shellQuote(value) {
|
|
1480
|
-
|
|
1481
|
-
|
|
1603
|
+
const text = String(value);
|
|
1604
|
+
if (/^[A-Za-z0-9_./:=,@%+-]+$/.test(text)) {
|
|
1605
|
+
return text;
|
|
1482
1606
|
}
|
|
1483
1607
|
|
|
1484
|
-
return `'${
|
|
1608
|
+
return `'${text.replaceAll("'", "'\\''")}'`;
|
|
1485
1609
|
}
|
|
1486
1610
|
|
|
1487
1611
|
async function promptForCapsuleName() {
|
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
|
|
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
|
-
|
|
264
|
-
|
|
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.
|
|
1
|
+
export const LAKEBED_VERSION = "0.0.15";
|