lakebed 0.0.18 → 0.0.20
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 +46 -30
- package/package.json +5 -5
- package/src/anonymous-server.js +393 -8
- package/src/anonymous.js +241 -0
- package/src/cli.js +401 -79
- package/src/client.d.ts +38 -0
- package/src/client.js +285 -3
- package/src/server.d.ts +49 -0
- package/src/server.js +53 -0
- package/src/source-runtime-worker.js +114 -1
- package/src/source-runtime.js +27 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -6,22 +6,16 @@ This package is an early public prototype. It includes the `lakebed` CLI, a loca
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
npm install -g lakebed
|
|
11
|
-
lakebed new
|
|
12
|
-
lakebed dev my-app
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Enter `my-app` when `lakebed new` asks for a capsule name.
|
|
16
|
-
|
|
17
|
-
Or run it without a global install:
|
|
9
|
+
Run Lakebed through `npx` for now:
|
|
18
10
|
|
|
19
11
|
```sh
|
|
20
12
|
npx lakebed new
|
|
21
|
-
|
|
13
|
+
npx lakebed dev my-app
|
|
22
14
|
```
|
|
23
15
|
|
|
24
|
-
|
|
16
|
+
Enter `my-app` when `npx lakebed new` asks for a capsule name.
|
|
17
|
+
|
|
18
|
+
`npx lakebed create` is an alias for `npx lakebed new`. New capsules get a git repository and initial commit unless they are created inside an existing git repository or `--no-git` is passed.
|
|
25
19
|
|
|
26
20
|
## Capsule Shape
|
|
27
21
|
|
|
@@ -114,23 +108,45 @@ queries: {
|
|
|
114
108
|
}
|
|
115
109
|
```
|
|
116
110
|
|
|
117
|
-
`lakebed dev` loads `.env.lakebed.server` locally. For hosted apps, claim the deploy, then run `lakebed deploy` to replace the deploy's server env with the file contents. Env values are not included in anonymous artifacts or source manifests.
|
|
111
|
+
`npx lakebed dev` loads `.env.lakebed.server` locally. For hosted apps, claim the deploy, then run `npx lakebed deploy` to replace the deploy's server env with the file contents. Env values are not included in anonymous artifacts or source manifests.
|
|
112
|
+
|
|
113
|
+
## External Endpoints
|
|
114
|
+
|
|
115
|
+
Define app-relative HTTP endpoints for webhooks and external services:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { endpoint, json, text } from "lakebed/server";
|
|
119
|
+
|
|
120
|
+
endpoints: {
|
|
121
|
+
incoming: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
|
|
122
|
+
if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
|
|
123
|
+
return text("unauthorized", { status: 401 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const payload = await req.json<{ body: string }>();
|
|
127
|
+
ctx.db.messages.insert({ body: payload.body, authorId: "webhook" });
|
|
128
|
+
return json({ ok: true });
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Endpoint handlers receive the same server context as queries and mutations. The request exposes headers, query params, and repeatable `text()`, `json()`, and `bytes()` body readers.
|
|
118
134
|
|
|
119
135
|
## Commands
|
|
120
136
|
|
|
121
137
|
```sh
|
|
122
|
-
lakebed new [name] [--template todo] [--no-git]
|
|
123
|
-
lakebed create [name] [--template todo] [--no-git]
|
|
124
|
-
lakebed dev [capsule-dir] [--port 3000]
|
|
125
|
-
lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
126
|
-
lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
|
|
127
|
-
lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
128
|
-
lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
|
|
129
|
-
lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
130
|
-
lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
|
|
131
|
-
lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
132
|
-
lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
133
|
-
lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
138
|
+
npx lakebed new [name] [--template todo] [--no-git]
|
|
139
|
+
npx lakebed create [name] [--template todo] [--no-git]
|
|
140
|
+
npx lakebed dev [capsule-dir] [--port 3000]
|
|
141
|
+
npx lakebed build [capsule-dir] --target anonymous [--out .lakebed/artifacts/app.json] [--json]
|
|
142
|
+
npx lakebed deploy [capsule-dir] [--api <url>] [--public-inspect] [--json]
|
|
143
|
+
npx lakebed claim [capsule-dir] [--api <url>] [--json]
|
|
144
|
+
npx lakebed domains add <subdomain.lakebed.app> [--api <url>] [--json]
|
|
145
|
+
npx lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
|
|
146
|
+
npx lakebed inspect <deploy-id-or-url> [--api <url>] [--inspect-token <token>] [--json]
|
|
147
|
+
npx lakebed db list [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
148
|
+
npx lakebed db dump [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
149
|
+
npx lakebed logs [deploy-id-or-url] [--port 3000] [--inspect-token <token>]
|
|
134
150
|
```
|
|
135
151
|
|
|
136
152
|
## Current Constraints
|
|
@@ -149,15 +165,15 @@ Once a Lakebed deploy runner is available, deploy a capsule with:
|
|
|
149
165
|
|
|
150
166
|
```sh
|
|
151
167
|
cd my-app
|
|
152
|
-
lakebed deploy
|
|
168
|
+
npx lakebed deploy
|
|
153
169
|
```
|
|
154
170
|
|
|
155
171
|
For local deploy-runner testing:
|
|
156
172
|
|
|
157
173
|
```sh
|
|
158
|
-
lakebed anonymous-server --port 8787
|
|
174
|
+
npx lakebed anonymous-server --port 8787
|
|
159
175
|
cd my-app
|
|
160
|
-
lakebed deploy --api http://localhost:8787
|
|
176
|
+
npx lakebed deploy --api http://localhost:8787
|
|
161
177
|
```
|
|
162
178
|
|
|
163
179
|
In production, set `PUBLIC_ROOT_URL` to the deploy API origin and `LAKEBED_APP_BASE_DOMAIN` to the app domain without the wildcard prefix:
|
|
@@ -189,7 +205,7 @@ LAKEBED_ANONYMOUS_CLEANUP_RETENTION=7d
|
|
|
189
205
|
LAKEBED_ANONYMOUS_CLEANUP_INTERVAL=1h
|
|
190
206
|
```
|
|
191
207
|
|
|
192
|
-
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:
|
|
208
|
+
Deploy responses include claim metadata. Configure GitHub OAuth on the runner, then run `npx lakebed claim` to open the claim page and attach the anonymous deploy to a developer account:
|
|
193
209
|
|
|
194
210
|
```sh
|
|
195
211
|
LAKEBED_GITHUB_CLIENT_ID=...
|
|
@@ -198,9 +214,9 @@ LAKEBED_SESSION_SECRET=...
|
|
|
198
214
|
LAKEBED_SERVER_ENV_SECRET=...
|
|
199
215
|
```
|
|
200
216
|
|
|
201
|
-
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.
|
|
217
|
+
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, `npx 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, `npx lakebed deploy` creates a claim-required preview, saves its claim metadata, and prints the `npx lakebed claim` command. Run that command, then run `npx 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.
|
|
202
218
|
|
|
203
|
-
Hosted inspection is private by default. `lakebed inspect`, `lakebed db list`, `lakebed db dump`, and `lakebed logs` send the saved claim token automatically when run from the capsule directory.
|
|
219
|
+
Hosted inspection is private by default. `npx lakebed inspect`, `npx lakebed db list`, `npx lakebed db dump`, and `npx lakebed logs` send the saved claim token automatically when run from the capsule directory.
|
|
204
220
|
|
|
205
221
|
After a deploy is claimed, reserve a Lakebed-owned app subdomain from the capsule directory:
|
|
206
222
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lakebed",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -52,6 +52,9 @@
|
|
|
52
52
|
"publishConfig": {
|
|
53
53
|
"access": "public"
|
|
54
54
|
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
|
|
57
|
+
},
|
|
55
58
|
"dependencies": {
|
|
56
59
|
"esbuild": "^0.27.1",
|
|
57
60
|
"pg": "^8.16.3",
|
|
@@ -60,8 +63,5 @@
|
|
|
60
63
|
},
|
|
61
64
|
"devDependencies": {
|
|
62
65
|
"@types/ws": "^8.18.1"
|
|
63
|
-
},
|
|
64
|
-
"scripts": {
|
|
65
|
-
"check": "node --check src/cli.js && node --check src/runtime.js && node --check src/server.js && node --check src/client.js && node --check src/source-store.js && node --check src/source-runtime.js && node --check src/source-runtime-worker.js && node --check src/source-runtime-loader.mjs && node --check src/anonymous.js && node --check src/anonymous-server.js && node --check src/auth.js && node --check src/version.js"
|
|
66
66
|
}
|
|
67
|
-
}
|
|
67
|
+
}
|
package/src/anonymous-server.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createSlug,
|
|
9
9
|
DEFAULT_ANONYMOUS_LIMITS,
|
|
10
10
|
LAKEBED_VERSION,
|
|
11
|
+
executeAnonymousEndpoint,
|
|
11
12
|
executeAnonymousMutation,
|
|
12
13
|
executeAnonymousQuery,
|
|
13
14
|
hashClaimToken,
|
|
@@ -27,6 +28,8 @@ function anonymousDeployExpiresAt() {
|
|
|
27
28
|
return new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const endpointBodyMaxBytes = 2 * 1024 * 1024;
|
|
32
|
+
|
|
30
33
|
function dayWindowStart() {
|
|
31
34
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`;
|
|
32
35
|
}
|
|
@@ -120,7 +123,7 @@ function websocketSend(ws, message) {
|
|
|
120
123
|
ws.send(JSON.stringify(message));
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
async function
|
|
126
|
+
async function readRequestBody(req, maxBytes = endpointBodyMaxBytes) {
|
|
124
127
|
const chunks = [];
|
|
125
128
|
let total = 0;
|
|
126
129
|
for await (const chunk of req) {
|
|
@@ -131,11 +134,72 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
|
131
134
|
chunks.push(chunk);
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
|
|
137
|
+
return Buffer.concat(chunks);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
|
|
141
|
+
const body = await readRequestBody(req, maxBytes);
|
|
142
|
+
if (body.byteLength === 0) {
|
|
135
143
|
return {};
|
|
136
144
|
}
|
|
137
145
|
|
|
138
|
-
return JSON.parse(
|
|
146
|
+
return JSON.parse(body.toString("utf8"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function headersFromNodeRequest(headers) {
|
|
150
|
+
const clean = {};
|
|
151
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
clean[name.toLowerCase()] = value.join(", ");
|
|
154
|
+
} else if (value !== undefined) {
|
|
155
|
+
clean[name.toLowerCase()] = String(value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return clean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function endpointRequestPayload({ appPath, body, req, requestUrl }) {
|
|
162
|
+
return {
|
|
163
|
+
bodyBase64: body.toString("base64"),
|
|
164
|
+
headers: headersFromNodeRequest(req.headers),
|
|
165
|
+
method: String(req.method ?? "GET").toUpperCase(),
|
|
166
|
+
path: appPath,
|
|
167
|
+
url: requestUrl.href
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findArtifactEndpoint(artifact, method, path) {
|
|
172
|
+
const requestMethod = String(method ?? "GET").toUpperCase();
|
|
173
|
+
for (const [name, endpoint] of Object.entries(artifact.server?.endpoints ?? {})) {
|
|
174
|
+
if (endpoint?.method === requestMethod && endpoint?.path === path) {
|
|
175
|
+
return { ...endpoint, name };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sanitizeEndpointResponseHeaders(headers = {}) {
|
|
182
|
+
const blocked = new Set(["connection", "content-length", "date", "keep-alive", "transfer-encoding", "upgrade"]);
|
|
183
|
+
const clean = {};
|
|
184
|
+
for (const [rawName, rawValue] of Object.entries(headers ?? {})) {
|
|
185
|
+
const name = String(rawName);
|
|
186
|
+
const lower = name.toLowerCase();
|
|
187
|
+
if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/i.test(name) || blocked.has(lower)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
clean[name] = String(rawValue);
|
|
191
|
+
}
|
|
192
|
+
return clean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sendEndpointResponse(res, response) {
|
|
196
|
+
const body = Buffer.from(response?.bodyBase64 ?? "", "base64");
|
|
197
|
+
const status = Number.isInteger(response?.status) && response.status >= 100 && response.status <= 599 ? response.status : 200;
|
|
198
|
+
res.writeHead(status, {
|
|
199
|
+
...sanitizeEndpointResponseHeaders(response?.headers),
|
|
200
|
+
"Content-Length": String(body.byteLength)
|
|
201
|
+
});
|
|
202
|
+
res.end(body);
|
|
139
203
|
}
|
|
140
204
|
|
|
141
205
|
function bearerToken(req) {
|
|
@@ -410,6 +474,26 @@ function routeSystemPath(pathname) {
|
|
|
410
474
|
return pathname;
|
|
411
475
|
}
|
|
412
476
|
|
|
477
|
+
function wantsHtml(req) {
|
|
478
|
+
const accept = String(req.headers.accept ?? "");
|
|
479
|
+
return !accept || accept.includes("text/html");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isReservedClientShellPath(pathname) {
|
|
483
|
+
return (
|
|
484
|
+
pathname === "/client.js" ||
|
|
485
|
+
pathname === "/__lakebed" ||
|
|
486
|
+
pathname.startsWith("/__lakebed/") ||
|
|
487
|
+
pathname === "/__span" ||
|
|
488
|
+
pathname.startsWith("/__span/") ||
|
|
489
|
+
(pathname.startsWith("/auth/") && pathname !== "/auth/callback")
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isClientShellRequest(req, pathname) {
|
|
494
|
+
return req.method === "GET" && wantsHtml(req) && !isReservedClientShellPath(pathname);
|
|
495
|
+
}
|
|
496
|
+
|
|
413
497
|
function parsePathDeploy(url) {
|
|
414
498
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
415
499
|
if (parts[0] !== "d" || !parts[1]) {
|
|
@@ -2893,6 +2977,22 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2893
2977
|
${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
|
|
2894
2978
|
${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
|
|
2895
2979
|
</div>
|
|
2980
|
+
<div class="deploy-actions">
|
|
2981
|
+
${
|
|
2982
|
+
deploy.status === "active"
|
|
2983
|
+
? `<button class="button danger terminate-button" type="button" data-terminate-deploy-id="${escapeHtml(deploy.deployId)}" data-terminate-deploy-name="${escapeHtml(deploy.name)}" aria-label="Terminate ${escapeHtml(deploy.name)}">
|
|
2984
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
|
2985
|
+
<path d="M3 6h18"></path>
|
|
2986
|
+
<path d="M8 6V4h8v2"></path>
|
|
2987
|
+
<path d="M19 6l-1 14H6L5 6"></path>
|
|
2988
|
+
<path d="M10 11v6"></path>
|
|
2989
|
+
<path d="M14 11v6"></path>
|
|
2990
|
+
</svg>
|
|
2991
|
+
<span>Terminate</span>
|
|
2992
|
+
</button>`
|
|
2993
|
+
: ""
|
|
2994
|
+
}
|
|
2995
|
+
</div>
|
|
2896
2996
|
</article>`;
|
|
2897
2997
|
})
|
|
2898
2998
|
.join("");
|
|
@@ -3004,11 +3104,30 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3004
3104
|
text-decoration: none;
|
|
3005
3105
|
}
|
|
3006
3106
|
|
|
3107
|
+
.button:disabled {
|
|
3108
|
+
cursor: wait;
|
|
3109
|
+
opacity: 0.55;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3007
3112
|
.button.secondary {
|
|
3008
3113
|
background: transparent;
|
|
3009
3114
|
color: var(--text);
|
|
3010
3115
|
}
|
|
3011
3116
|
|
|
3117
|
+
.button.danger {
|
|
3118
|
+
color: var(--danger);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
.button.danger:hover {
|
|
3122
|
+
border-color: var(--danger);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
.button svg {
|
|
3126
|
+
height: 16px;
|
|
3127
|
+
margin-right: 8px;
|
|
3128
|
+
width: 16px;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3012
3131
|
.summary {
|
|
3013
3132
|
background: var(--line);
|
|
3014
3133
|
border: 1px solid var(--line);
|
|
@@ -3061,7 +3180,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3061
3180
|
align-items: center;
|
|
3062
3181
|
display: grid;
|
|
3063
3182
|
gap: 24px;
|
|
3064
|
-
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
|
|
3183
|
+
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px) minmax(120px, 150px);
|
|
3065
3184
|
}
|
|
3066
3185
|
|
|
3067
3186
|
.list-head {
|
|
@@ -3088,6 +3207,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3088
3207
|
.deploy-main,
|
|
3089
3208
|
.activity,
|
|
3090
3209
|
.usage-grid,
|
|
3210
|
+
.deploy-actions,
|
|
3091
3211
|
.quota {
|
|
3092
3212
|
min-width: 0;
|
|
3093
3213
|
}
|
|
@@ -3203,6 +3323,64 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3203
3323
|
width: var(--usage);
|
|
3204
3324
|
}
|
|
3205
3325
|
|
|
3326
|
+
.deploy-actions {
|
|
3327
|
+
display: flex;
|
|
3328
|
+
justify-content: flex-end;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
.terminate-button {
|
|
3332
|
+
min-width: 126px;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
.confirm-dialog {
|
|
3336
|
+
background: var(--panel-raised);
|
|
3337
|
+
border: 1px solid var(--line-strong);
|
|
3338
|
+
border-radius: 8px;
|
|
3339
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
|
|
3340
|
+
color: var(--text);
|
|
3341
|
+
margin: auto;
|
|
3342
|
+
max-width: 430px;
|
|
3343
|
+
padding: 0;
|
|
3344
|
+
width: calc(100% - 32px);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
.confirm-dialog::backdrop {
|
|
3348
|
+
background: rgba(0, 0, 0, 0.72);
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
.dialog-body {
|
|
3352
|
+
display: grid;
|
|
3353
|
+
gap: 14px;
|
|
3354
|
+
padding: 20px;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
.confirm-dialog h2 {
|
|
3358
|
+
font-size: 20px;
|
|
3359
|
+
line-height: 1.2;
|
|
3360
|
+
margin: 0;
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
.confirm-dialog p {
|
|
3364
|
+
color: var(--soft);
|
|
3365
|
+
line-height: 1.5;
|
|
3366
|
+
margin: 0;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
.dialog-actions {
|
|
3370
|
+
display: flex;
|
|
3371
|
+
flex-wrap: wrap;
|
|
3372
|
+
gap: 10px;
|
|
3373
|
+
justify-content: flex-end;
|
|
3374
|
+
margin-top: 4px;
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
.dialog-error {
|
|
3378
|
+
color: var(--danger);
|
|
3379
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
3380
|
+
font-size: 12px;
|
|
3381
|
+
min-height: 16px;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3206
3384
|
@media (max-width: 980px) {
|
|
3207
3385
|
.shell {
|
|
3208
3386
|
padding: 24px 18px 40px;
|
|
@@ -3234,6 +3412,10 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3234
3412
|
.activity-item {
|
|
3235
3413
|
justify-content: flex-start;
|
|
3236
3414
|
}
|
|
3415
|
+
|
|
3416
|
+
.deploy-actions {
|
|
3417
|
+
justify-content: flex-start;
|
|
3418
|
+
}
|
|
3237
3419
|
}
|
|
3238
3420
|
|
|
3239
3421
|
@media (max-width: 640px) {
|
|
@@ -3298,12 +3480,122 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3298
3480
|
<span>Deploy</span>
|
|
3299
3481
|
<span>Activity</span>
|
|
3300
3482
|
<span>Usage today</span>
|
|
3483
|
+
<span>Actions</span>
|
|
3301
3484
|
</div>
|
|
3302
3485
|
${rows}
|
|
3303
3486
|
</div>
|
|
3304
3487
|
</section>`
|
|
3305
3488
|
}
|
|
3306
3489
|
</main>
|
|
3490
|
+
${
|
|
3491
|
+
signedIn && activeDeploys > 0
|
|
3492
|
+
? `<dialog class="confirm-dialog" id="terminate-dialog" aria-describedby="terminate-description" aria-labelledby="terminate-title">
|
|
3493
|
+
<div class="dialog-body">
|
|
3494
|
+
<h2 id="terminate-title">Terminate app?</h2>
|
|
3495
|
+
<p id="terminate-description">This stops serving <strong id="terminate-deploy-name">this app</strong>. Data and logs stay available for inspection.</p>
|
|
3496
|
+
<div class="dialog-error" id="terminate-error" role="alert"></div>
|
|
3497
|
+
<div class="dialog-actions">
|
|
3498
|
+
<button class="button secondary" id="terminate-cancel" type="button">Cancel</button>
|
|
3499
|
+
<button class="button danger" id="terminate-confirm" type="button">
|
|
3500
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
|
3501
|
+
<path d="M3 6h18"></path>
|
|
3502
|
+
<path d="M8 6V4h8v2"></path>
|
|
3503
|
+
<path d="M19 6l-1 14H6L5 6"></path>
|
|
3504
|
+
<path d="M10 11v6"></path>
|
|
3505
|
+
<path d="M14 11v6"></path>
|
|
3506
|
+
</svg>
|
|
3507
|
+
<span>Terminate</span>
|
|
3508
|
+
</button>
|
|
3509
|
+
</div>
|
|
3510
|
+
</div>
|
|
3511
|
+
</dialog>
|
|
3512
|
+
<script>
|
|
3513
|
+
(() => {
|
|
3514
|
+
const dialog = document.getElementById("terminate-dialog");
|
|
3515
|
+
const name = document.getElementById("terminate-deploy-name");
|
|
3516
|
+
const cancel = document.getElementById("terminate-cancel");
|
|
3517
|
+
const confirm = document.getElementById("terminate-confirm");
|
|
3518
|
+
const error = document.getElementById("terminate-error");
|
|
3519
|
+
let pendingDeployId = "";
|
|
3520
|
+
|
|
3521
|
+
function setBusy(value) {
|
|
3522
|
+
confirm.disabled = value;
|
|
3523
|
+
cancel.disabled = value;
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
function closeDialog() {
|
|
3527
|
+
if (typeof dialog.close === "function") {
|
|
3528
|
+
dialog.close();
|
|
3529
|
+
} else {
|
|
3530
|
+
dialog.removeAttribute("open");
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
function openDialog(button) {
|
|
3535
|
+
pendingDeployId = button.dataset.terminateDeployId || "";
|
|
3536
|
+
name.textContent = button.dataset.terminateDeployName || "this app";
|
|
3537
|
+
error.textContent = "";
|
|
3538
|
+
setBusy(false);
|
|
3539
|
+
if (typeof dialog.showModal === "function") {
|
|
3540
|
+
dialog.showModal();
|
|
3541
|
+
} else {
|
|
3542
|
+
dialog.setAttribute("open", "");
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
async function errorMessage(response) {
|
|
3547
|
+
const text = await response.text();
|
|
3548
|
+
if (!text) {
|
|
3549
|
+
return response.statusText || "Terminate failed.";
|
|
3550
|
+
}
|
|
3551
|
+
try {
|
|
3552
|
+
const body = JSON.parse(text);
|
|
3553
|
+
return body.error || response.statusText || "Terminate failed.";
|
|
3554
|
+
} catch {
|
|
3555
|
+
return text;
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
document.addEventListener("click", (event) => {
|
|
3560
|
+
const button = event.target.closest("[data-terminate-deploy-id]");
|
|
3561
|
+
if (!button) {
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3564
|
+
openDialog(button);
|
|
3565
|
+
});
|
|
3566
|
+
|
|
3567
|
+
dialog.addEventListener("click", (event) => {
|
|
3568
|
+
if (event.target === dialog && !confirm.disabled) {
|
|
3569
|
+
closeDialog();
|
|
3570
|
+
}
|
|
3571
|
+
});
|
|
3572
|
+
|
|
3573
|
+
cancel.addEventListener("click", closeDialog);
|
|
3574
|
+
confirm.addEventListener("click", async () => {
|
|
3575
|
+
if (!pendingDeployId) {
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
setBusy(true);
|
|
3579
|
+
error.textContent = "";
|
|
3580
|
+
const response = await fetch("/v1/me/deploys/" + encodeURIComponent(pendingDeployId) + "/terminate", {
|
|
3581
|
+
headers: { "Accept": "application/json" },
|
|
3582
|
+
method: "POST"
|
|
3583
|
+
});
|
|
3584
|
+
if (response.status === 401) {
|
|
3585
|
+
window.location.assign("/auth/github?return_to=" + encodeURIComponent("/deploys"));
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
if (!response.ok) {
|
|
3589
|
+
error.textContent = await errorMessage(response);
|
|
3590
|
+
setBusy(false);
|
|
3591
|
+
return;
|
|
3592
|
+
}
|
|
3593
|
+
window.location.assign("/deploys");
|
|
3594
|
+
});
|
|
3595
|
+
})();
|
|
3596
|
+
</script>`
|
|
3597
|
+
: ""
|
|
3598
|
+
}
|
|
3307
3599
|
</body>
|
|
3308
3600
|
</html>`;
|
|
3309
3601
|
}
|
|
@@ -5499,6 +5791,11 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
|
|
|
5499
5791
|
clientBundleHash: deploy.clientBundleHash,
|
|
5500
5792
|
deployId: deploy.id,
|
|
5501
5793
|
domains,
|
|
5794
|
+
endpoints: Object.entries(artifact.server.endpoints ?? {}).map(([name, endpoint]) => ({
|
|
5795
|
+
method: endpoint.method,
|
|
5796
|
+
name,
|
|
5797
|
+
path: endpoint.path
|
|
5798
|
+
})),
|
|
5502
5799
|
expiresAt: deploy.expiresAt,
|
|
5503
5800
|
inspectPolicy: inspectPolicyForDeploy(deploy),
|
|
5504
5801
|
limits: deploy.limits,
|
|
@@ -5516,15 +5813,15 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
|
|
|
5516
5813
|
|
|
5517
5814
|
function inspectCommandForPath(deploy, systemPath) {
|
|
5518
5815
|
if (systemPath === "/__lakebed/db") {
|
|
5519
|
-
return `lakebed db dump ${deploy.id}`;
|
|
5816
|
+
return `npx lakebed db dump ${deploy.id}`;
|
|
5520
5817
|
}
|
|
5521
5818
|
if (systemPath === "/__lakebed/db/tables") {
|
|
5522
|
-
return `lakebed db list ${deploy.id}`;
|
|
5819
|
+
return `npx lakebed db list ${deploy.id}`;
|
|
5523
5820
|
}
|
|
5524
5821
|
if (systemPath === "/__lakebed/logs") {
|
|
5525
|
-
return `lakebed logs ${deploy.id}`;
|
|
5822
|
+
return `npx lakebed logs ${deploy.id}`;
|
|
5526
5823
|
}
|
|
5527
|
-
return `lakebed inspect ${deploy.id}`;
|
|
5824
|
+
return `npx lakebed inspect ${deploy.id}`;
|
|
5528
5825
|
}
|
|
5529
5826
|
|
|
5530
5827
|
function inspectAuthFailure(deploy, systemPath) {
|
|
@@ -5749,6 +6046,15 @@ export async function startAnonymousServer({
|
|
|
5749
6046
|
return resolvedStore.listDeploysForOwner(user.id);
|
|
5750
6047
|
}
|
|
5751
6048
|
|
|
6049
|
+
async function developerSummaryForDeploy(deploy) {
|
|
6050
|
+
const artifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
6051
|
+
return developerDeploySummary({
|
|
6052
|
+
artifact: artifact?.artifact,
|
|
6053
|
+
deploy,
|
|
6054
|
+
usage: await resolvedStore.readUsage(deploy.id)
|
|
6055
|
+
});
|
|
6056
|
+
}
|
|
6057
|
+
|
|
5752
6058
|
async function enforceAnonymousDeployCreation(req) {
|
|
5753
6059
|
if (deployCreationPolicy.disabled) {
|
|
5754
6060
|
const error = new LakebedQuotaError({
|
|
@@ -5923,6 +6229,43 @@ export async function startAnonymousServer({
|
|
|
5923
6229
|
return;
|
|
5924
6230
|
}
|
|
5925
6231
|
|
|
6232
|
+
const developerTerminateMatch =
|
|
6233
|
+
req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
|
|
6234
|
+
if (developerTerminateMatch) {
|
|
6235
|
+
const user = currentDeveloper(req);
|
|
6236
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
|
|
6237
|
+
sendJson(res, 401, { error: "Developer authentication required." });
|
|
6238
|
+
return;
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
const deployId = decodeURIComponent(developerTerminateMatch[1]);
|
|
6242
|
+
const currentDeploy = await resolvedStore.getDeployById(deployId);
|
|
6243
|
+
if (!currentDeploy || currentDeploy.ownerId !== user.id) {
|
|
6244
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6245
|
+
return;
|
|
6246
|
+
}
|
|
6247
|
+
if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
|
|
6248
|
+
sendJson(res, 409, { error: "Deploy is not active." });
|
|
6249
|
+
return;
|
|
6250
|
+
}
|
|
6251
|
+
|
|
6252
|
+
const deploy = await resolvedStore.terminateDeploy(deployId);
|
|
6253
|
+
if (!deploy || deploy.ownerId !== user.id) {
|
|
6254
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6255
|
+
return;
|
|
6256
|
+
}
|
|
6257
|
+
|
|
6258
|
+
await resolvedStore.appendLog(deploy.id, "warn", "anonymous deploy terminated", {
|
|
6259
|
+
ownerId: user.id,
|
|
6260
|
+
ownerLogin: user.login,
|
|
6261
|
+
source: "developer"
|
|
6262
|
+
});
|
|
6263
|
+
await refreshDeploySubscriptions(deploy);
|
|
6264
|
+
closeDeployConnections(deploy.id);
|
|
6265
|
+
sendJson(res, 200, { deploy: await developerSummaryForDeploy(deploy) });
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
|
|
5926
6269
|
if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
|
|
5927
6270
|
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
|
|
5928
6271
|
sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
@@ -6414,6 +6757,48 @@ export async function startAnonymousServer({
|
|
|
6414
6757
|
return;
|
|
6415
6758
|
}
|
|
6416
6759
|
|
|
6760
|
+
const endpoint = findArtifactEndpoint(loaded.artifact, req.method, appPath);
|
|
6761
|
+
if (endpoint) {
|
|
6762
|
+
await enforceClientTrafficQuota(clientKey, "mutations");
|
|
6763
|
+
await resolvedStore.incrementQuota(
|
|
6764
|
+
loaded.deploy.id,
|
|
6765
|
+
"mutations",
|
|
6766
|
+
quotaLimitForBucket("mutations", loaded.deploy)
|
|
6767
|
+
);
|
|
6768
|
+
const auth = await resolveAuthFromUrl({
|
|
6769
|
+
defaultAuth: createGuestAuth("local"),
|
|
6770
|
+
onError: (error) =>
|
|
6771
|
+
resolvedStore.appendLog(loaded.deploy.id, "warn", "endpoint auth verification failed", {
|
|
6772
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6773
|
+
}),
|
|
6774
|
+
origin: requestOrigin(req, loaded.deploy.url),
|
|
6775
|
+
shooBaseUrl,
|
|
6776
|
+
url: requestUrl
|
|
6777
|
+
});
|
|
6778
|
+
const body = await readRequestBody(req);
|
|
6779
|
+
const response = await executeAnonymousEndpoint({
|
|
6780
|
+
artifact: loaded.artifact,
|
|
6781
|
+
auth,
|
|
6782
|
+
deployId: loaded.deploy.id,
|
|
6783
|
+
limits: loaded.deploy.limits,
|
|
6784
|
+
name: endpoint.name,
|
|
6785
|
+
request: endpointRequestPayload({ appPath, body, req, requestUrl }),
|
|
6786
|
+
sourceRuntime: resolvedSourceRuntime,
|
|
6787
|
+
state: resolvedStore
|
|
6788
|
+
});
|
|
6789
|
+
sendEndpointResponse(res, response);
|
|
6790
|
+
await publishDeploy(loaded.deploy.id);
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6793
|
+
|
|
6794
|
+
if (isClientShellRequest(req, appPath)) {
|
|
6795
|
+
sendText(res, 200, html(loaded.artifact.name ?? "Lakebed Capsule", loaded.basePath, { clientBundleHash: loaded.deploy.clientBundleHash, shooBaseUrl }), {
|
|
6796
|
+
"Cache-Control": "no-store",
|
|
6797
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
6798
|
+
});
|
|
6799
|
+
return;
|
|
6800
|
+
}
|
|
6801
|
+
|
|
6417
6802
|
sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
6418
6803
|
} catch (error) {
|
|
6419
6804
|
if (isQuotaError(error)) {
|