lakebed 0.0.18 → 0.0.19
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 +365 -8
- package/src/anonymous.js +241 -0
- package/src/cli.js +288 -47
- 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.19",
|
|
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) {
|
|
@@ -2893,6 +2957,22 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2893
2957
|
${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
|
|
2894
2958
|
${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
|
|
2895
2959
|
</div>
|
|
2960
|
+
<div class="deploy-actions">
|
|
2961
|
+
${
|
|
2962
|
+
deploy.status === "active"
|
|
2963
|
+
? `<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)}">
|
|
2964
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
|
2965
|
+
<path d="M3 6h18"></path>
|
|
2966
|
+
<path d="M8 6V4h8v2"></path>
|
|
2967
|
+
<path d="M19 6l-1 14H6L5 6"></path>
|
|
2968
|
+
<path d="M10 11v6"></path>
|
|
2969
|
+
<path d="M14 11v6"></path>
|
|
2970
|
+
</svg>
|
|
2971
|
+
<span>Terminate</span>
|
|
2972
|
+
</button>`
|
|
2973
|
+
: ""
|
|
2974
|
+
}
|
|
2975
|
+
</div>
|
|
2896
2976
|
</article>`;
|
|
2897
2977
|
})
|
|
2898
2978
|
.join("");
|
|
@@ -3004,11 +3084,30 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3004
3084
|
text-decoration: none;
|
|
3005
3085
|
}
|
|
3006
3086
|
|
|
3087
|
+
.button:disabled {
|
|
3088
|
+
cursor: wait;
|
|
3089
|
+
opacity: 0.55;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3007
3092
|
.button.secondary {
|
|
3008
3093
|
background: transparent;
|
|
3009
3094
|
color: var(--text);
|
|
3010
3095
|
}
|
|
3011
3096
|
|
|
3097
|
+
.button.danger {
|
|
3098
|
+
color: var(--danger);
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
.button.danger:hover {
|
|
3102
|
+
border-color: var(--danger);
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
.button svg {
|
|
3106
|
+
height: 16px;
|
|
3107
|
+
margin-right: 8px;
|
|
3108
|
+
width: 16px;
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3012
3111
|
.summary {
|
|
3013
3112
|
background: var(--line);
|
|
3014
3113
|
border: 1px solid var(--line);
|
|
@@ -3061,7 +3160,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3061
3160
|
align-items: center;
|
|
3062
3161
|
display: grid;
|
|
3063
3162
|
gap: 24px;
|
|
3064
|
-
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
|
|
3163
|
+
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px) minmax(120px, 150px);
|
|
3065
3164
|
}
|
|
3066
3165
|
|
|
3067
3166
|
.list-head {
|
|
@@ -3088,6 +3187,7 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3088
3187
|
.deploy-main,
|
|
3089
3188
|
.activity,
|
|
3090
3189
|
.usage-grid,
|
|
3190
|
+
.deploy-actions,
|
|
3091
3191
|
.quota {
|
|
3092
3192
|
min-width: 0;
|
|
3093
3193
|
}
|
|
@@ -3203,6 +3303,64 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3203
3303
|
width: var(--usage);
|
|
3204
3304
|
}
|
|
3205
3305
|
|
|
3306
|
+
.deploy-actions {
|
|
3307
|
+
display: flex;
|
|
3308
|
+
justify-content: flex-end;
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
.terminate-button {
|
|
3312
|
+
min-width: 126px;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
.confirm-dialog {
|
|
3316
|
+
background: var(--panel-raised);
|
|
3317
|
+
border: 1px solid var(--line-strong);
|
|
3318
|
+
border-radius: 8px;
|
|
3319
|
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55);
|
|
3320
|
+
color: var(--text);
|
|
3321
|
+
margin: auto;
|
|
3322
|
+
max-width: 430px;
|
|
3323
|
+
padding: 0;
|
|
3324
|
+
width: calc(100% - 32px);
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
.confirm-dialog::backdrop {
|
|
3328
|
+
background: rgba(0, 0, 0, 0.72);
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
.dialog-body {
|
|
3332
|
+
display: grid;
|
|
3333
|
+
gap: 14px;
|
|
3334
|
+
padding: 20px;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
.confirm-dialog h2 {
|
|
3338
|
+
font-size: 20px;
|
|
3339
|
+
line-height: 1.2;
|
|
3340
|
+
margin: 0;
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
.confirm-dialog p {
|
|
3344
|
+
color: var(--soft);
|
|
3345
|
+
line-height: 1.5;
|
|
3346
|
+
margin: 0;
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
.dialog-actions {
|
|
3350
|
+
display: flex;
|
|
3351
|
+
flex-wrap: wrap;
|
|
3352
|
+
gap: 10px;
|
|
3353
|
+
justify-content: flex-end;
|
|
3354
|
+
margin-top: 4px;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
.dialog-error {
|
|
3358
|
+
color: var(--danger);
|
|
3359
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
3360
|
+
font-size: 12px;
|
|
3361
|
+
min-height: 16px;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3206
3364
|
@media (max-width: 980px) {
|
|
3207
3365
|
.shell {
|
|
3208
3366
|
padding: 24px 18px 40px;
|
|
@@ -3234,6 +3392,10 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3234
3392
|
.activity-item {
|
|
3235
3393
|
justify-content: flex-start;
|
|
3236
3394
|
}
|
|
3395
|
+
|
|
3396
|
+
.deploy-actions {
|
|
3397
|
+
justify-content: flex-start;
|
|
3398
|
+
}
|
|
3237
3399
|
}
|
|
3238
3400
|
|
|
3239
3401
|
@media (max-width: 640px) {
|
|
@@ -3298,12 +3460,122 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
3298
3460
|
<span>Deploy</span>
|
|
3299
3461
|
<span>Activity</span>
|
|
3300
3462
|
<span>Usage today</span>
|
|
3463
|
+
<span>Actions</span>
|
|
3301
3464
|
</div>
|
|
3302
3465
|
${rows}
|
|
3303
3466
|
</div>
|
|
3304
3467
|
</section>`
|
|
3305
3468
|
}
|
|
3306
3469
|
</main>
|
|
3470
|
+
${
|
|
3471
|
+
signedIn && activeDeploys > 0
|
|
3472
|
+
? `<dialog class="confirm-dialog" id="terminate-dialog" aria-describedby="terminate-description" aria-labelledby="terminate-title">
|
|
3473
|
+
<div class="dialog-body">
|
|
3474
|
+
<h2 id="terminate-title">Terminate app?</h2>
|
|
3475
|
+
<p id="terminate-description">This stops serving <strong id="terminate-deploy-name">this app</strong>. Data and logs stay available for inspection.</p>
|
|
3476
|
+
<div class="dialog-error" id="terminate-error" role="alert"></div>
|
|
3477
|
+
<div class="dialog-actions">
|
|
3478
|
+
<button class="button secondary" id="terminate-cancel" type="button">Cancel</button>
|
|
3479
|
+
<button class="button danger" id="terminate-confirm" type="button">
|
|
3480
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
|
3481
|
+
<path d="M3 6h18"></path>
|
|
3482
|
+
<path d="M8 6V4h8v2"></path>
|
|
3483
|
+
<path d="M19 6l-1 14H6L5 6"></path>
|
|
3484
|
+
<path d="M10 11v6"></path>
|
|
3485
|
+
<path d="M14 11v6"></path>
|
|
3486
|
+
</svg>
|
|
3487
|
+
<span>Terminate</span>
|
|
3488
|
+
</button>
|
|
3489
|
+
</div>
|
|
3490
|
+
</div>
|
|
3491
|
+
</dialog>
|
|
3492
|
+
<script>
|
|
3493
|
+
(() => {
|
|
3494
|
+
const dialog = document.getElementById("terminate-dialog");
|
|
3495
|
+
const name = document.getElementById("terminate-deploy-name");
|
|
3496
|
+
const cancel = document.getElementById("terminate-cancel");
|
|
3497
|
+
const confirm = document.getElementById("terminate-confirm");
|
|
3498
|
+
const error = document.getElementById("terminate-error");
|
|
3499
|
+
let pendingDeployId = "";
|
|
3500
|
+
|
|
3501
|
+
function setBusy(value) {
|
|
3502
|
+
confirm.disabled = value;
|
|
3503
|
+
cancel.disabled = value;
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function closeDialog() {
|
|
3507
|
+
if (typeof dialog.close === "function") {
|
|
3508
|
+
dialog.close();
|
|
3509
|
+
} else {
|
|
3510
|
+
dialog.removeAttribute("open");
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
function openDialog(button) {
|
|
3515
|
+
pendingDeployId = button.dataset.terminateDeployId || "";
|
|
3516
|
+
name.textContent = button.dataset.terminateDeployName || "this app";
|
|
3517
|
+
error.textContent = "";
|
|
3518
|
+
setBusy(false);
|
|
3519
|
+
if (typeof dialog.showModal === "function") {
|
|
3520
|
+
dialog.showModal();
|
|
3521
|
+
} else {
|
|
3522
|
+
dialog.setAttribute("open", "");
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
async function errorMessage(response) {
|
|
3527
|
+
const text = await response.text();
|
|
3528
|
+
if (!text) {
|
|
3529
|
+
return response.statusText || "Terminate failed.";
|
|
3530
|
+
}
|
|
3531
|
+
try {
|
|
3532
|
+
const body = JSON.parse(text);
|
|
3533
|
+
return body.error || response.statusText || "Terminate failed.";
|
|
3534
|
+
} catch {
|
|
3535
|
+
return text;
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
document.addEventListener("click", (event) => {
|
|
3540
|
+
const button = event.target.closest("[data-terminate-deploy-id]");
|
|
3541
|
+
if (!button) {
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
openDialog(button);
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3547
|
+
dialog.addEventListener("click", (event) => {
|
|
3548
|
+
if (event.target === dialog && !confirm.disabled) {
|
|
3549
|
+
closeDialog();
|
|
3550
|
+
}
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
cancel.addEventListener("click", closeDialog);
|
|
3554
|
+
confirm.addEventListener("click", async () => {
|
|
3555
|
+
if (!pendingDeployId) {
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
setBusy(true);
|
|
3559
|
+
error.textContent = "";
|
|
3560
|
+
const response = await fetch("/v1/me/deploys/" + encodeURIComponent(pendingDeployId) + "/terminate", {
|
|
3561
|
+
headers: { "Accept": "application/json" },
|
|
3562
|
+
method: "POST"
|
|
3563
|
+
});
|
|
3564
|
+
if (response.status === 401) {
|
|
3565
|
+
window.location.assign("/auth/github?return_to=" + encodeURIComponent("/deploys"));
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
if (!response.ok) {
|
|
3569
|
+
error.textContent = await errorMessage(response);
|
|
3570
|
+
setBusy(false);
|
|
3571
|
+
return;
|
|
3572
|
+
}
|
|
3573
|
+
window.location.assign("/deploys");
|
|
3574
|
+
});
|
|
3575
|
+
})();
|
|
3576
|
+
</script>`
|
|
3577
|
+
: ""
|
|
3578
|
+
}
|
|
3307
3579
|
</body>
|
|
3308
3580
|
</html>`;
|
|
3309
3581
|
}
|
|
@@ -5499,6 +5771,11 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
|
|
|
5499
5771
|
clientBundleHash: deploy.clientBundleHash,
|
|
5500
5772
|
deployId: deploy.id,
|
|
5501
5773
|
domains,
|
|
5774
|
+
endpoints: Object.entries(artifact.server.endpoints ?? {}).map(([name, endpoint]) => ({
|
|
5775
|
+
method: endpoint.method,
|
|
5776
|
+
name,
|
|
5777
|
+
path: endpoint.path
|
|
5778
|
+
})),
|
|
5502
5779
|
expiresAt: deploy.expiresAt,
|
|
5503
5780
|
inspectPolicy: inspectPolicyForDeploy(deploy),
|
|
5504
5781
|
limits: deploy.limits,
|
|
@@ -5516,15 +5793,15 @@ function fullManifestForDeploy({ artifact, deploy, domains = [] }) {
|
|
|
5516
5793
|
|
|
5517
5794
|
function inspectCommandForPath(deploy, systemPath) {
|
|
5518
5795
|
if (systemPath === "/__lakebed/db") {
|
|
5519
|
-
return `lakebed db dump ${deploy.id}`;
|
|
5796
|
+
return `npx lakebed db dump ${deploy.id}`;
|
|
5520
5797
|
}
|
|
5521
5798
|
if (systemPath === "/__lakebed/db/tables") {
|
|
5522
|
-
return `lakebed db list ${deploy.id}`;
|
|
5799
|
+
return `npx lakebed db list ${deploy.id}`;
|
|
5523
5800
|
}
|
|
5524
5801
|
if (systemPath === "/__lakebed/logs") {
|
|
5525
|
-
return `lakebed logs ${deploy.id}`;
|
|
5802
|
+
return `npx lakebed logs ${deploy.id}`;
|
|
5526
5803
|
}
|
|
5527
|
-
return `lakebed inspect ${deploy.id}`;
|
|
5804
|
+
return `npx lakebed inspect ${deploy.id}`;
|
|
5528
5805
|
}
|
|
5529
5806
|
|
|
5530
5807
|
function inspectAuthFailure(deploy, systemPath) {
|
|
@@ -5749,6 +6026,15 @@ export async function startAnonymousServer({
|
|
|
5749
6026
|
return resolvedStore.listDeploysForOwner(user.id);
|
|
5750
6027
|
}
|
|
5751
6028
|
|
|
6029
|
+
async function developerSummaryForDeploy(deploy) {
|
|
6030
|
+
const artifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
6031
|
+
return developerDeploySummary({
|
|
6032
|
+
artifact: artifact?.artifact,
|
|
6033
|
+
deploy,
|
|
6034
|
+
usage: await resolvedStore.readUsage(deploy.id)
|
|
6035
|
+
});
|
|
6036
|
+
}
|
|
6037
|
+
|
|
5752
6038
|
async function enforceAnonymousDeployCreation(req) {
|
|
5753
6039
|
if (deployCreationPolicy.disabled) {
|
|
5754
6040
|
const error = new LakebedQuotaError({
|
|
@@ -5923,6 +6209,43 @@ export async function startAnonymousServer({
|
|
|
5923
6209
|
return;
|
|
5924
6210
|
}
|
|
5925
6211
|
|
|
6212
|
+
const developerTerminateMatch =
|
|
6213
|
+
req.method === "POST" ? requestUrl.pathname.match(/^\/v1\/me\/deploys\/([^/]+)\/terminate$/) : null;
|
|
6214
|
+
if (developerTerminateMatch) {
|
|
6215
|
+
const user = currentDeveloper(req);
|
|
6216
|
+
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret) || !user) {
|
|
6217
|
+
sendJson(res, 401, { error: "Developer authentication required." });
|
|
6218
|
+
return;
|
|
6219
|
+
}
|
|
6220
|
+
|
|
6221
|
+
const deployId = decodeURIComponent(developerTerminateMatch[1]);
|
|
6222
|
+
const currentDeploy = await resolvedStore.getDeployById(deployId);
|
|
6223
|
+
if (!currentDeploy || currentDeploy.ownerId !== user.id) {
|
|
6224
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6225
|
+
return;
|
|
6226
|
+
}
|
|
6227
|
+
if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
|
|
6228
|
+
sendJson(res, 409, { error: "Deploy is not active." });
|
|
6229
|
+
return;
|
|
6230
|
+
}
|
|
6231
|
+
|
|
6232
|
+
const deploy = await resolvedStore.terminateDeploy(deployId);
|
|
6233
|
+
if (!deploy || deploy.ownerId !== user.id) {
|
|
6234
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6235
|
+
return;
|
|
6236
|
+
}
|
|
6237
|
+
|
|
6238
|
+
await resolvedStore.appendLog(deploy.id, "warn", "anonymous deploy terminated", {
|
|
6239
|
+
ownerId: user.id,
|
|
6240
|
+
ownerLogin: user.login,
|
|
6241
|
+
source: "developer"
|
|
6242
|
+
});
|
|
6243
|
+
await refreshDeploySubscriptions(deploy);
|
|
6244
|
+
closeDeployConnections(deploy.id);
|
|
6245
|
+
sendJson(res, 200, { deploy: await developerSummaryForDeploy(deploy) });
|
|
6246
|
+
return;
|
|
6247
|
+
}
|
|
6248
|
+
|
|
5926
6249
|
if (req.method === "GET" && requestUrl.pathname === "/auth/github") {
|
|
5927
6250
|
if (!developerAuthConfigured(resolvedGithubOAuth, resolvedDeveloperSessionSecret)) {
|
|
5928
6251
|
sendText(res, 503, "GitHub sign-in is not configured.\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
@@ -6414,6 +6737,40 @@ export async function startAnonymousServer({
|
|
|
6414
6737
|
return;
|
|
6415
6738
|
}
|
|
6416
6739
|
|
|
6740
|
+
const endpoint = findArtifactEndpoint(loaded.artifact, req.method, appPath);
|
|
6741
|
+
if (endpoint) {
|
|
6742
|
+
await enforceClientTrafficQuota(clientKey, "mutations");
|
|
6743
|
+
await resolvedStore.incrementQuota(
|
|
6744
|
+
loaded.deploy.id,
|
|
6745
|
+
"mutations",
|
|
6746
|
+
quotaLimitForBucket("mutations", loaded.deploy)
|
|
6747
|
+
);
|
|
6748
|
+
const auth = await resolveAuthFromUrl({
|
|
6749
|
+
defaultAuth: createGuestAuth("local"),
|
|
6750
|
+
onError: (error) =>
|
|
6751
|
+
resolvedStore.appendLog(loaded.deploy.id, "warn", "endpoint auth verification failed", {
|
|
6752
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6753
|
+
}),
|
|
6754
|
+
origin: requestOrigin(req, loaded.deploy.url),
|
|
6755
|
+
shooBaseUrl,
|
|
6756
|
+
url: requestUrl
|
|
6757
|
+
});
|
|
6758
|
+
const body = await readRequestBody(req);
|
|
6759
|
+
const response = await executeAnonymousEndpoint({
|
|
6760
|
+
artifact: loaded.artifact,
|
|
6761
|
+
auth,
|
|
6762
|
+
deployId: loaded.deploy.id,
|
|
6763
|
+
limits: loaded.deploy.limits,
|
|
6764
|
+
name: endpoint.name,
|
|
6765
|
+
request: endpointRequestPayload({ appPath, body, req, requestUrl }),
|
|
6766
|
+
sourceRuntime: resolvedSourceRuntime,
|
|
6767
|
+
state: resolvedStore
|
|
6768
|
+
});
|
|
6769
|
+
sendEndpointResponse(res, response);
|
|
6770
|
+
await publishDeploy(loaded.deploy.id);
|
|
6771
|
+
return;
|
|
6772
|
+
}
|
|
6773
|
+
|
|
6417
6774
|
sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
6418
6775
|
} catch (error) {
|
|
6419
6776
|
if (isQuotaError(error)) {
|