wirl 0.2.0 → 0.2.2
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 +13 -10
- package/bin.js +1322 -202
- package/package.json +1 -1
package/bin.js
CHANGED
|
@@ -15,10 +15,14 @@ var __export = (target, all) => {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// packages/core/src/types.ts
|
|
18
|
-
var AppKind;
|
|
18
|
+
var AppRole, AppKind;
|
|
19
19
|
var init_types = __esm({
|
|
20
20
|
"packages/core/src/types.ts"() {
|
|
21
21
|
"use strict";
|
|
22
|
+
AppRole = {
|
|
23
|
+
VIEWER: "viewer",
|
|
24
|
+
EDITOR: "editor"
|
|
25
|
+
};
|
|
22
26
|
AppKind = {
|
|
23
27
|
STATIC: "static",
|
|
24
28
|
SERVICE: "service",
|
|
@@ -189,9 +193,33 @@ function validateSlug(input) {
|
|
|
189
193
|
function isWellFormedSlug(input) {
|
|
190
194
|
return typeof input === "string" && input.length >= SLUG_MIN && input.length <= SLUG_MAX && SLUG_RE.test(input) && !input.includes(HOST_SEPARATOR);
|
|
191
195
|
}
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
function slugBase(input) {
|
|
197
|
+
return input.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, SLUG_MAX).replace(/-$/, "");
|
|
198
|
+
}
|
|
199
|
+
function previewSlugFor(raw) {
|
|
200
|
+
const base = slugBase(raw);
|
|
201
|
+
switch (validateSlug(base).problem) {
|
|
202
|
+
case void 0:
|
|
203
|
+
return { ok: true, slug: base };
|
|
204
|
+
case "reserved":
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
problem: "reserved",
|
|
208
|
+
message: `The preview name "${base}" is reserved. The reserved preview names are: ${[...RESERVED_SLUGS].sort().join(", ")}. Pick another name.`
|
|
209
|
+
};
|
|
210
|
+
case "too_short":
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
problem: "too_short",
|
|
214
|
+
message: `That name is too short: it makes a preview name of one character, and a preview name needs at least ${String(SLUG_MIN)}.`
|
|
215
|
+
};
|
|
216
|
+
default:
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
problem: "no_characters",
|
|
220
|
+
message: "That name has no usable characters: a preview name is made of the letters a to z and the digits 0 to 9 in what you give it, and none are left."
|
|
221
|
+
};
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
function appLabel(orgSlug, appSlug) {
|
|
197
225
|
return `${orgSlug}${HOST_SEPARATOR}${appSlug}`;
|
|
@@ -384,13 +412,14 @@ async function readSigned(key, token) {
|
|
|
384
412
|
}
|
|
385
413
|
return { ok: true, claims };
|
|
386
414
|
}
|
|
387
|
-
var encoder, decoder2, PREFIX, SKEW_SECONDS;
|
|
415
|
+
var encoder, decoder2, PREFIX, GRANT_TTL_DEFAULT_SECONDS, SKEW_SECONDS;
|
|
388
416
|
var init_token = __esm({
|
|
389
417
|
"packages/core/src/token.ts"() {
|
|
390
418
|
"use strict";
|
|
391
419
|
encoder = new TextEncoder();
|
|
392
420
|
decoder2 = new TextDecoder();
|
|
393
421
|
PREFIX = "w1";
|
|
422
|
+
GRANT_TTL_DEFAULT_SECONDS = 12 * 60;
|
|
394
423
|
SKEW_SECONDS = 60;
|
|
395
424
|
}
|
|
396
425
|
});
|
|
@@ -569,10 +598,19 @@ var init_egress = __esm({
|
|
|
569
598
|
PinnedFetchError = class extends Error {
|
|
570
599
|
reason;
|
|
571
600
|
host;
|
|
572
|
-
|
|
601
|
+
/**
|
|
602
|
+
* Set only on a timeout that ended an answer already under way, whose body
|
|
603
|
+
* the fetch bounds itself (the broker's streams): which bound it was, the
|
|
604
|
+
* time since the headers or the time with nothing moving, and its length in
|
|
605
|
+
* milliseconds. The broker reads it to say what it cut a stream for.
|
|
606
|
+
* Undefined on every other failure.
|
|
607
|
+
*/
|
|
608
|
+
streamBound;
|
|
609
|
+
constructor(reason, host, message, streamBound) {
|
|
573
610
|
super(message);
|
|
574
611
|
this.reason = reason;
|
|
575
612
|
this.host = host;
|
|
613
|
+
this.streamBound = streamBound;
|
|
576
614
|
}
|
|
577
615
|
};
|
|
578
616
|
PRIVATE_V4 = /^(0\.|10\.|127\.|169\.254\.|192\.0\.0\.|192\.168\.|198\.1[89]\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|(22[4-9]|23[0-9]|24[0-9]|25[0-5])\.)/;
|
|
@@ -2301,6 +2339,9 @@ var init_migrations = __esm({
|
|
|
2301
2339
|
leaseMs: 6e5,
|
|
2302
2340
|
orgApplies: 2,
|
|
2303
2341
|
// per org: the limit one tenant's applies meet
|
|
2342
|
+
// Per app, its live database and its previews' together, inside the org's
|
|
2343
|
+
// two, so one app's deployers can never take both (OIM-927).
|
|
2344
|
+
appApplies: 1,
|
|
2304
2345
|
// Per control-plane process. The lease holds no pooled connection, so this
|
|
2305
2346
|
// bounds the process's work, not its pool; at `orgApplies` two each,
|
|
2306
2347
|
// filling it takes eight orgs (T5b-6).
|
|
@@ -3299,12 +3340,50 @@ var init_runtime = __esm({
|
|
|
3299
3340
|
});
|
|
3300
3341
|
|
|
3301
3342
|
// packages/core/src/broker-stream.ts
|
|
3343
|
+
function mediaTypeOf(headers) {
|
|
3344
|
+
const declared = headers.get("content-type");
|
|
3345
|
+
if (declared === null) return null;
|
|
3346
|
+
return (declared.split(";")[0] ?? "").trim().toLowerCase();
|
|
3347
|
+
}
|
|
3302
3348
|
function isStreamedAnswer(status, headers) {
|
|
3303
3349
|
if (status < 200 || status > 299) return false;
|
|
3304
|
-
const
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3350
|
+
const media = mediaTypeOf(headers);
|
|
3351
|
+
return media !== null && STREAMED_MEDIA_TYPES.includes(media);
|
|
3352
|
+
}
|
|
3353
|
+
function isEventStream(headers) {
|
|
3354
|
+
return mediaTypeOf(headers) === "text/event-stream";
|
|
3355
|
+
}
|
|
3356
|
+
function closesWithEvent(headers) {
|
|
3357
|
+
if (!isEventStream(headers)) return false;
|
|
3358
|
+
const encoding = headers.get("content-encoding");
|
|
3359
|
+
return encoding === null || encoding.trim().toLowerCase() === "identity";
|
|
3360
|
+
}
|
|
3361
|
+
function lengthOf(ms) {
|
|
3362
|
+
const unit = (count, name) => `${count} ${name}${count === 1 ? "" : "s"}`;
|
|
3363
|
+
if (ms % 6e4 === 0) return unit(ms / 6e4, "minute");
|
|
3364
|
+
if (ms % 1e3 === 0) return unit(ms / 1e3, "second");
|
|
3365
|
+
return `${ms} ms`;
|
|
3366
|
+
}
|
|
3367
|
+
function sayCut(cut2) {
|
|
3368
|
+
switch (cut2.cause) {
|
|
3369
|
+
case "size":
|
|
3370
|
+
return `it reached wirl's limit of ${cut2.bytes % MIB === 0 ? `${cut2.bytes / MIB} MiB` : `${cut2.bytes} bytes`} for one answer.`;
|
|
3371
|
+
case "time":
|
|
3372
|
+
return `it was still open ${lengthOf(cut2.ms)} after the vendor began answering, the longest wirl allows.`;
|
|
3373
|
+
case "idle":
|
|
3374
|
+
return `nothing moved on it for ${lengthOf(cut2.ms)}, the longest wirl allows.`;
|
|
3375
|
+
case "held":
|
|
3376
|
+
return `it was still open ${lengthOf(cut2.ms)} after wirl began passing it on, the longest wirl allows.`;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
function streamCutEvent(cut2) {
|
|
3380
|
+
const data = JSON.stringify({ type: "error", error: { type: "api_error", message: `wirl cut this stream: ${sayCut(cut2)}` } });
|
|
3381
|
+
return new TextEncoder().encode(`
|
|
3382
|
+
|
|
3383
|
+
event: error
|
|
3384
|
+
data: ${data}
|
|
3385
|
+
|
|
3386
|
+
`);
|
|
3308
3387
|
}
|
|
3309
3388
|
function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
|
|
3310
3389
|
const held = /* @__PURE__ */ new Map();
|
|
@@ -3329,7 +3408,7 @@ function createStreamSlots(perApp = BROKER_LIMITS.openStreamsPerApp) {
|
|
|
3329
3408
|
}
|
|
3330
3409
|
};
|
|
3331
3410
|
}
|
|
3332
|
-
var BROKER_LIMITS, STREAMED_MEDIA_TYPES;
|
|
3411
|
+
var BROKER_LIMITS, STREAMED_MEDIA_TYPES, CUT_EVENT_RESERVE_BYTES, MIB;
|
|
3333
3412
|
var init_broker_stream = __esm({
|
|
3334
3413
|
"packages/core/src/broker-stream.ts"() {
|
|
3335
3414
|
"use strict";
|
|
@@ -3346,20 +3425,369 @@ var init_broker_stream = __esm({
|
|
|
3346
3425
|
streamIdleMs: 4 * 6e4,
|
|
3347
3426
|
/** Bytes in any one answer, streamed or read whole. */
|
|
3348
3427
|
answerBytes: 25 * 1024 * 1024,
|
|
3428
|
+
/**
|
|
3429
|
+
* Bytes in the body of any one call an app makes. The body is read whole
|
|
3430
|
+
* before the call is sent, and before its grant is looked up, so a body
|
|
3431
|
+
* past this never opens a key and never reaches a vendor (OIM-931).
|
|
3432
|
+
* 100 MiB: Cloudflare publishes 100 MB as the request body its Free and
|
|
3433
|
+
* Pro plans take, and a deployed app's call is first held whole by the
|
|
3434
|
+
* egress Worker, in an isolate Cloudflare gives 128 MB of memory. Nothing
|
|
3435
|
+
* else bounds a caller that holds a broker token and reaches the control
|
|
3436
|
+
* plane itself: Railway sets no size on a body, only five minutes to send it.
|
|
3437
|
+
*/
|
|
3438
|
+
requestBytes: 100 * 1024 * 1024,
|
|
3439
|
+
/**
|
|
3440
|
+
* Bytes of call bodies one app's calls hold at once, its previews' with
|
|
3441
|
+
* them (OIM-951): counted as each chunk arrives, from a call's first byte
|
|
3442
|
+
* until the broker has answered it (broker-bodies.ts). At least
|
|
3443
|
+
* `requestBytes`, so one call at the cap always fits an app that holds
|
|
3444
|
+
* nothing else; a quarter of `heldBodyBytes`, so four apps of four owners
|
|
3445
|
+
* at their share fill the whole (one owner's apps cannot: `ownerHeldBodyBytes`).
|
|
3446
|
+
*/
|
|
3447
|
+
appHeldBodyBytes: 128 * 1024 * 1024,
|
|
3448
|
+
/**
|
|
3449
|
+
* Bytes of call bodies the calls of every app one owner has hold at once,
|
|
3450
|
+
* their previews' with them (OIM-967): the person `apps.owner_user_id`
|
|
3451
|
+
* names, whichever organisations their apps are in. Apps and
|
|
3452
|
+
* organisations cost nothing to create (no cap on either), so a share per
|
|
3453
|
+
* app let one person with four apps fill the whole, and one per
|
|
3454
|
+
* organisation would let them do it with four organisations; a person
|
|
3455
|
+
* costs a sign-in. Two apps at their share, so one app that runs
|
|
3456
|
+
* away still leaves its owner's other apps room, and half of
|
|
3457
|
+
* `heldBodyBytes`, so one owner can never take more than half, and a
|
|
3458
|
+
* second sign-in is what it takes to fill it. At least `requestBytes`, so
|
|
3459
|
+
* one call at the cap always fits an owner whose apps hold nothing else.
|
|
3460
|
+
*/
|
|
3461
|
+
ownerHeldBodyBytes: 256 * 1024 * 1024,
|
|
3462
|
+
/**
|
|
3463
|
+
* Bytes of call bodies every app's calls hold at once, in this process.
|
|
3464
|
+
* A call's reader starts at 16 KiB and doubles its buffer as the body
|
|
3465
|
+
* arrives, so the buffers behind this hold at most twice it, 1 GiB, and
|
|
3466
|
+
* 16 KiB a call beside (the ruling in OIM-951).
|
|
3467
|
+
*/
|
|
3468
|
+
heldBodyBytes: 512 * 1024 * 1024,
|
|
3469
|
+
/** The `Retry-After` of a call refused for either. */
|
|
3470
|
+
heldBodyRetryAfterSeconds: 10,
|
|
3471
|
+
/**
|
|
3472
|
+
* How long a call's body may send nothing before its read is stopped:
|
|
3473
|
+
* an upload's (OIM-935). A deployed app's body comes from the egress
|
|
3474
|
+
* Worker, which holds it whole before it sends it, and `wirl dev` reads it
|
|
3475
|
+
* whole first, so no ordinary sender ever pauses (fix round 1, R1-F2).
|
|
3476
|
+
*/
|
|
3477
|
+
heldBodyStallMs: 3e4,
|
|
3478
|
+
/**
|
|
3479
|
+
* How long a call's body may take to arrive whole: 100 MiB in 120 seconds
|
|
3480
|
+
* is about 7 Mbit/s, which a body between Cloudflare and the control plane
|
|
3481
|
+
* passes many times over, and well under Node's 300 seconds a request,
|
|
3482
|
+
* after which the body would be cut anyway (fix round 1, R1-F2).
|
|
3483
|
+
*/
|
|
3484
|
+
heldBodyArrivalMs: 12e4,
|
|
3485
|
+
/**
|
|
3486
|
+
* Bytes of answers read whole that one app's calls hold at once, its
|
|
3487
|
+
* previews' with them (OIM-966): counted as each chunk arrives from the
|
|
3488
|
+
* vendor, from an answer's first byte until wirl's server has taken the
|
|
3489
|
+
* last of it, or the app has gone (broker-bodies.ts). A vendor's event
|
|
3490
|
+
* stream is not counted: it is passed on as the app reads it, and holds
|
|
3491
|
+
* about 285 to 441 KiB at a time, measured end to end through the control
|
|
3492
|
+
* plane's server (`passOn` in broker.ts; OIM-966 fix round 1, R1-F3,
|
|
3493
|
+
* R2-F2). More than two `answerBytes`, so two answers at the cap fit an
|
|
3494
|
+
* app that holds nothing else; a quarter of `heldAnswerBytes`, so four apps
|
|
3495
|
+
* of four owners at their share fill the whole (one owner's apps cannot:
|
|
3496
|
+
* `ownerHeldAnswerBytes`).
|
|
3497
|
+
*/
|
|
3498
|
+
appHeldAnswerBytes: 64 * 1024 * 1024,
|
|
3499
|
+
/**
|
|
3500
|
+
* Bytes of answers read whole that the calls of every app one owner has
|
|
3501
|
+
* hold at once, their previews' with them (OIM-967), for the reasons
|
|
3502
|
+
* `ownerHeldBodyBytes` gives: two apps at their share, half of
|
|
3503
|
+
* `heldAnswerBytes`, and more than two `answerBytes` and than the room a
|
|
3504
|
+
* call needs, so an owner whose apps hold nothing else can always send.
|
|
3505
|
+
*/
|
|
3506
|
+
ownerHeldAnswerBytes: 128 * 1024 * 1024,
|
|
3507
|
+
/**
|
|
3508
|
+
* Bytes of answers read whole that every app's calls hold at once, in this
|
|
3509
|
+
* process. An answer's reader doubles its buffer as it fills, as a body's
|
|
3510
|
+
* does, and never past `answerBytes`, so while answers arrive the buffers
|
|
3511
|
+
* behind this hold at most twice it, 512 MiB, and 16 KiB an answer beside;
|
|
3512
|
+
* an answer read whole is copied to exactly its size before it is handed
|
|
3513
|
+
* on, so from then its count is its memory (the ruling in OIM-966, and its
|
|
3514
|
+
* fix round 1, R1-F2).
|
|
3515
|
+
*/
|
|
3516
|
+
heldAnswerBytes: 256 * 1024 * 1024,
|
|
3517
|
+
/**
|
|
3518
|
+
* The room an app's share of answers, and the whole, must each have left
|
|
3519
|
+
* for a call to be sent at all: one answer at the cap. With less, the call
|
|
3520
|
+
* is refused with a 503 before it leaves wirl, rather than run at its
|
|
3521
|
+
* vendor and its answer cut (OIM-966 fix round 1, R1-F1).
|
|
3522
|
+
*/
|
|
3523
|
+
heldAnswerRoomBytes: 25 * 1024 * 1024,
|
|
3524
|
+
/** The `Retry-After` of a call refused for that room, before it was sent. */
|
|
3525
|
+
heldAnswerRetryAfterSeconds: 10,
|
|
3526
|
+
/**
|
|
3527
|
+
* How long an answer read whole may wait for wirl's server to take the
|
|
3528
|
+
* last of it once it is handed back: past this it is cut, and its count
|
|
3529
|
+
* goes back, so no app holds its share, or the whole, by not reading.
|
|
3530
|
+
* Under Cloudflare's measured 125 seconds with nothing moving, which ends
|
|
3531
|
+
* a deployed app's unread answer anyway (OIM-966 fix round 1, R1-F1).
|
|
3532
|
+
*/
|
|
3533
|
+
heldAnswerHandOutMs: 12e4,
|
|
3349
3534
|
/**
|
|
3350
3535
|
* Streamed answers one app may have open at once. Each holds a vendor
|
|
3351
3536
|
* socket and an inbound connection on one control-plane instance for up
|
|
3352
3537
|
* to fourteen minutes, so one app cannot be allowed an unbounded number.
|
|
3353
3538
|
* Counted in memory, per instance, like the control plane's other limits.
|
|
3354
3539
|
*/
|
|
3355
|
-
openStreamsPerApp: 20
|
|
3540
|
+
openStreamsPerApp: 20,
|
|
3541
|
+
/**
|
|
3542
|
+
* How long an event stream wirl cut waits for its closing event to leave
|
|
3543
|
+
* the server before it ends the stream with its error anyway (OIM-837 fix
|
|
3544
|
+
* round 1). The event is on its way when the server's queue of what it has
|
|
3545
|
+
* to write has emptied; a reader that is slow but reading empties a full
|
|
3546
|
+
* kernel buffer, a few hundred kilobytes, well inside this. Past it the
|
|
3547
|
+
* reader is not reading, and the stream ends as it always did; the write
|
|
3548
|
+
* stall that holds a connection longer than this is OIM-968's.
|
|
3549
|
+
*/
|
|
3550
|
+
cutEventSentMs: 5e3
|
|
3356
3551
|
};
|
|
3357
3552
|
STREAMED_MEDIA_TYPES = Object.freeze(["text/event-stream", "application/x-ndjson"]);
|
|
3553
|
+
CUT_EVENT_RESERVE_BYTES = 512;
|
|
3554
|
+
MIB = 1024 * 1024;
|
|
3555
|
+
}
|
|
3556
|
+
});
|
|
3557
|
+
|
|
3558
|
+
// packages/core/src/refusals.ts
|
|
3559
|
+
function sayAtMostEvery(log, now, everyMs = SAY_EVERY_MS) {
|
|
3560
|
+
let since = 0;
|
|
3561
|
+
let saidAt = Number.NEGATIVE_INFINITY;
|
|
3562
|
+
return (line) => {
|
|
3563
|
+
since += 1;
|
|
3564
|
+
const at2 = now();
|
|
3565
|
+
if (at2 - saidAt < everyMs) return;
|
|
3566
|
+
const text = line(since, at2);
|
|
3567
|
+
since = 0;
|
|
3568
|
+
saidAt = at2;
|
|
3569
|
+
try {
|
|
3570
|
+
log(text);
|
|
3571
|
+
} catch {
|
|
3572
|
+
}
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
var SAY_EVERY_MS, MiB, sizeOf;
|
|
3576
|
+
var init_refusals = __esm({
|
|
3577
|
+
"packages/core/src/refusals.ts"() {
|
|
3578
|
+
"use strict";
|
|
3579
|
+
SAY_EVERY_MS = 3e4;
|
|
3580
|
+
MiB = 1024 * 1024;
|
|
3581
|
+
sizeOf = (bytes) => bytes >= MiB ? `${String(Math.floor(bytes / MiB))} MiB` : `${String(Math.ceil(bytes / 1024))} KiB`;
|
|
3582
|
+
}
|
|
3583
|
+
});
|
|
3584
|
+
|
|
3585
|
+
// packages/core/src/wait-clock.ts
|
|
3586
|
+
var systemWaitClock;
|
|
3587
|
+
var init_wait_clock = __esm({
|
|
3588
|
+
"packages/core/src/wait-clock.ts"() {
|
|
3589
|
+
"use strict";
|
|
3590
|
+
systemWaitClock = {
|
|
3591
|
+
wait(ms) {
|
|
3592
|
+
let timer;
|
|
3593
|
+
const done = new Promise((resolve7) => {
|
|
3594
|
+
timer = setTimeout(resolve7, ms);
|
|
3595
|
+
timer.unref?.();
|
|
3596
|
+
});
|
|
3597
|
+
return { done, cancel: () => clearTimeout(timer) };
|
|
3598
|
+
}
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
});
|
|
3602
|
+
|
|
3603
|
+
// packages/core/src/broker-bodies.ts
|
|
3604
|
+
function heldBodyRefusal(limits, why) {
|
|
3605
|
+
const again = `Send it again in ${String(limits.heldBodyRetryAfterSeconds)} seconds.`;
|
|
3606
|
+
if (why === "app") {
|
|
3607
|
+
return {
|
|
3608
|
+
error: "app_bodies_full",
|
|
3609
|
+
message: `This call's body would take the bodies this app's calls hold at once past ${sizeOf(limits.appHeldBodyBytes)}, the most one app's calls may hold, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3610
|
+
};
|
|
3611
|
+
}
|
|
3612
|
+
if (why === "owner") {
|
|
3613
|
+
return {
|
|
3614
|
+
error: "owner_bodies_full",
|
|
3615
|
+
message: `This call's body would take the bodies held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldBodyBytes)}, the most one owner's apps may hold together, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3616
|
+
};
|
|
3617
|
+
}
|
|
3618
|
+
return {
|
|
3619
|
+
error: "bodies_full",
|
|
3620
|
+
message: `This call's body would take the bodies wirl holds for all apps' calls at once past ${sizeOf(limits.heldBodyBytes)}, the most it holds, so wirl stopped the call there and sent nothing to the vendor. ${again}`
|
|
3621
|
+
};
|
|
3622
|
+
}
|
|
3623
|
+
function heldBodyCut(limits, why) {
|
|
3624
|
+
if (why === "stalled") {
|
|
3625
|
+
return {
|
|
3626
|
+
error: "body_stalled",
|
|
3627
|
+
message: `Nothing of this call's body arrived for ${String(limits.heldBodyStallMs / 1e3)} seconds, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
|
|
3628
|
+
};
|
|
3629
|
+
}
|
|
3630
|
+
return {
|
|
3631
|
+
error: "body_too_slow",
|
|
3632
|
+
message: `This call's body had not all arrived after ${String(limits.heldBodyArrivalMs / 1e3)} seconds, the longest wirl waits for one, so wirl stopped the call there and sent nothing to the vendor. Send it again.`
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
function heldAnswerRefusal(limits, why) {
|
|
3636
|
+
const acted = "The vendor may have acted on the call: send it again only if repeating it is safe.";
|
|
3637
|
+
if (why === "app") {
|
|
3638
|
+
return {
|
|
3639
|
+
error: "app_answers_full",
|
|
3640
|
+
message: `The vendor received this call and answered it, but its answer would take the answers this app's calls hold at once past ${sizeOf(limits.appHeldAnswerBytes)}, the most one app's calls may hold, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
if (why === "owner") {
|
|
3644
|
+
return {
|
|
3645
|
+
error: "owner_answers_full",
|
|
3646
|
+
message: `The vendor received this call and answered it, but its answer would take the answers held at once for the calls of all the apps this app's owner has past ${sizeOf(limits.ownerHeldAnswerBytes)}, the most one owner's apps may hold together, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3649
|
+
return {
|
|
3650
|
+
error: "answers_full",
|
|
3651
|
+
message: `The vendor received this call and answered it, but its answer would take the answers wirl holds for all apps' calls at once past ${sizeOf(limits.heldAnswerBytes)}, the most it holds, so wirl stopped reading the answer there and passed none of it on. ${acted}`
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
function heldAnswerNoRoom(limits, why) {
|
|
3655
|
+
const again = `Send it again in ${String(limits.heldAnswerRetryAfterSeconds)} seconds.`;
|
|
3656
|
+
if (why === "app") {
|
|
3657
|
+
return {
|
|
3658
|
+
error: "app_no_answer_room",
|
|
3659
|
+
message: `The answers this app's calls hold at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.appHeldAnswerBytes)} one app's calls may hold, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
if (why === "owner") {
|
|
3663
|
+
return {
|
|
3664
|
+
error: "owner_no_answer_room",
|
|
3665
|
+
message: `The answers held at once for the calls of all the apps this app's owner has leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.ownerHeldAnswerBytes)} one owner's apps may hold together, the room wirl keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
return {
|
|
3669
|
+
error: "no_answer_room",
|
|
3670
|
+
message: `The answers wirl holds for all apps' calls at once leave less than ${sizeOf(limits.heldAnswerRoomBytes)} of the ${sizeOf(limits.heldAnswerBytes)} it holds, the room it keeps for one more answer, so wirl did not send this call, and nothing of it reached the vendor. ${again}`
|
|
3671
|
+
};
|
|
3672
|
+
}
|
|
3673
|
+
function createHeldBodies(options = {}) {
|
|
3674
|
+
const limits = options.limits ?? {
|
|
3675
|
+
appHeldBodyBytes: BROKER_LIMITS.appHeldBodyBytes,
|
|
3676
|
+
ownerHeldBodyBytes: BROKER_LIMITS.ownerHeldBodyBytes,
|
|
3677
|
+
heldBodyBytes: BROKER_LIMITS.heldBodyBytes,
|
|
3678
|
+
heldBodyRetryAfterSeconds: BROKER_LIMITS.heldBodyRetryAfterSeconds,
|
|
3679
|
+
heldBodyStallMs: BROKER_LIMITS.heldBodyStallMs,
|
|
3680
|
+
heldBodyArrivalMs: BROKER_LIMITS.heldBodyArrivalMs
|
|
3681
|
+
};
|
|
3682
|
+
return {
|
|
3683
|
+
limits,
|
|
3684
|
+
clock: options.clock ?? systemWaitClock,
|
|
3685
|
+
...countHeld("bodies", limits.appHeldBodyBytes, limits.ownerHeldBodyBytes, limits.heldBodyBytes, options)
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
function createHeldAnswers(options = {}) {
|
|
3689
|
+
const limits = options.limits ?? {
|
|
3690
|
+
appHeldAnswerBytes: BROKER_LIMITS.appHeldAnswerBytes,
|
|
3691
|
+
ownerHeldAnswerBytes: BROKER_LIMITS.ownerHeldAnswerBytes,
|
|
3692
|
+
heldAnswerBytes: BROKER_LIMITS.heldAnswerBytes,
|
|
3693
|
+
heldAnswerRoomBytes: BROKER_LIMITS.heldAnswerRoomBytes,
|
|
3694
|
+
heldAnswerRetryAfterSeconds: BROKER_LIMITS.heldAnswerRetryAfterSeconds,
|
|
3695
|
+
heldAnswerHandOutMs: BROKER_LIMITS.heldAnswerHandOutMs
|
|
3696
|
+
};
|
|
3697
|
+
const { short, ...count } = countHeld("answers", limits.appHeldAnswerBytes, limits.ownerHeldAnswerBytes, limits.heldAnswerBytes, options);
|
|
3698
|
+
return {
|
|
3699
|
+
limits,
|
|
3700
|
+
clock: options.clock ?? systemWaitClock,
|
|
3701
|
+
...count,
|
|
3702
|
+
roomFor: (appId, ownerId) => short(appId, ownerId, limits.heldAnswerRoomBytes)
|
|
3703
|
+
};
|
|
3704
|
+
}
|
|
3705
|
+
function countHeld(what, appBytes, ownerBytes, allBytes, options) {
|
|
3706
|
+
const say = options.log === void 0 ? null : sayAtMostEvery(options.log, options.now ?? Date.now);
|
|
3707
|
+
const byApp = /* @__PURE__ */ new Map();
|
|
3708
|
+
const byOwner = /* @__PURE__ */ new Map();
|
|
3709
|
+
let total = 0;
|
|
3710
|
+
const named = (owner) => typeof owner === "string" && owner.length > 0 ? owner : null;
|
|
3711
|
+
const bump = (table, key, bytes) => {
|
|
3712
|
+
const next = (table.get(key) ?? 0) + bytes;
|
|
3713
|
+
if (next === 0) table.delete(key);
|
|
3714
|
+
else table.set(key, next);
|
|
3715
|
+
};
|
|
3716
|
+
const add = (appId, ownerId, bytes) => {
|
|
3717
|
+
bump(byApp, appId, bytes);
|
|
3718
|
+
if (ownerId !== null) bump(byOwner, ownerId, bytes);
|
|
3719
|
+
total += bytes;
|
|
3720
|
+
};
|
|
3721
|
+
const passes = (appId, ownerId, bytes) => (byApp.get(appId) ?? 0) + bytes > appBytes ? "app" : ownerId !== null && (byOwner.get(ownerId) ?? 0) + bytes > ownerBytes ? "owner" : total + bytes > allBytes ? "all" : null;
|
|
3722
|
+
const refused2 = (why, appId, ownerId) => {
|
|
3723
|
+
say?.((since) => {
|
|
3724
|
+
let most = ["nobody", 0];
|
|
3725
|
+
for (const entry of byApp) if (entry[1] > most[1]) most = entry;
|
|
3726
|
+
const who = why === "owner" && ownerId !== null ? `, owned by ${ownerId} (whose apps hold ${String(byOwner.get(ownerId) ?? 0)} bytes; ${String(byOwner.size)} ${byOwner.size === 1 ? "owner holds" : "owners hold"} any)` : "";
|
|
3727
|
+
return `wirl: refused ${String(since)} ${since === 1 ? "broker call" : "broker calls"} for the ${what} held at once since this line was last written (at most once every 30 seconds), the latest for ${why === "app" ? "its app's share" : why === "owner" ? "its owner's share" : "the whole"}, from ${appId}${who}; ${String(total)} of ${String(allBytes)} bytes held, by ${String(byApp.size)} ${byApp.size === 1 ? "app" : "apps"}, the most by ${most[0]} (${String(most[1])} bytes)`;
|
|
3728
|
+
});
|
|
3729
|
+
};
|
|
3730
|
+
return {
|
|
3731
|
+
open(appId, owner) {
|
|
3732
|
+
const ownerId = named(owner);
|
|
3733
|
+
let counted = 0;
|
|
3734
|
+
let refusal = null;
|
|
3735
|
+
let done = false;
|
|
3736
|
+
return {
|
|
3737
|
+
arrive(bytes) {
|
|
3738
|
+
if (done || !isSize(bytes)) return false;
|
|
3739
|
+
const why = passes(appId, ownerId, bytes);
|
|
3740
|
+
if (why !== null) {
|
|
3741
|
+
refusal = why;
|
|
3742
|
+
refused2(why, appId, ownerId);
|
|
3743
|
+
return false;
|
|
3744
|
+
}
|
|
3745
|
+
if (bytes > 0) add(appId, ownerId, bytes);
|
|
3746
|
+
counted += bytes;
|
|
3747
|
+
return true;
|
|
3748
|
+
},
|
|
3749
|
+
refusal: () => refusal,
|
|
3750
|
+
release() {
|
|
3751
|
+
if (done) return;
|
|
3752
|
+
done = true;
|
|
3753
|
+
if (counted > 0) add(appId, ownerId, -counted);
|
|
3754
|
+
counted = 0;
|
|
3755
|
+
}
|
|
3756
|
+
};
|
|
3757
|
+
},
|
|
3758
|
+
held: () => total,
|
|
3759
|
+
heldBy: (appId) => byApp.get(appId) ?? 0,
|
|
3760
|
+
heldByOwner: (ownerId) => byOwner.get(ownerId) ?? 0,
|
|
3761
|
+
short(appId, owner, bytes) {
|
|
3762
|
+
const ownerId = named(owner);
|
|
3763
|
+
const why = passes(appId, ownerId, bytes);
|
|
3764
|
+
if (why !== null) refused2(why, appId, ownerId);
|
|
3765
|
+
return why;
|
|
3766
|
+
}
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3769
|
+
var isSize;
|
|
3770
|
+
var init_broker_bodies = __esm({
|
|
3771
|
+
"packages/core/src/broker-bodies.ts"() {
|
|
3772
|
+
"use strict";
|
|
3773
|
+
init_broker_stream();
|
|
3774
|
+
init_refusals();
|
|
3775
|
+
init_wait_clock();
|
|
3776
|
+
isSize = (bytes) => Number.isSafeInteger(bytes) && bytes >= 0;
|
|
3358
3777
|
}
|
|
3359
3778
|
});
|
|
3360
3779
|
|
|
3361
3780
|
// packages/core/src/broker.ts
|
|
3362
3781
|
async function handleBrokerRequest(request, deps) {
|
|
3782
|
+
const holding = { body: null, answer: null };
|
|
3783
|
+
try {
|
|
3784
|
+
return await brokerCall(request, deps, holding);
|
|
3785
|
+
} finally {
|
|
3786
|
+
holding.body?.release();
|
|
3787
|
+
holding.answer?.release();
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
async function brokerCall(request, deps, holding) {
|
|
3363
3791
|
const url = new URL(request.url);
|
|
3364
3792
|
const clock = deps.now ? { now: deps.now() } : {};
|
|
3365
3793
|
const bearer = readBearer(request);
|
|
@@ -3404,6 +3832,55 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3404
3832
|
origin
|
|
3405
3833
|
});
|
|
3406
3834
|
};
|
|
3835
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
3836
|
+
const bodyLimit = deps.maxRequestBytes ?? BROKER_LIMITS.requestBytes;
|
|
3837
|
+
const bodyDeclared = hasBody ? declaredLength(request.headers) : null;
|
|
3838
|
+
const declaredTooLarge = bodyDeclared !== null && bodyDeclared > bodyLimit;
|
|
3839
|
+
const ownerId = request.method === "HEAD" || declaredTooLarge ? null : await ownerOfApp(deps, appId);
|
|
3840
|
+
let requestBody = null;
|
|
3841
|
+
if (hasBody) {
|
|
3842
|
+
const budget = deps.heldBodies;
|
|
3843
|
+
const held = budget?.open(appId, ownerId) ?? null;
|
|
3844
|
+
holding.body = held;
|
|
3845
|
+
const bodyRead = declaredTooLarge ? { outcome: "too_large" } : await readWithin(
|
|
3846
|
+
request.body,
|
|
3847
|
+
bodyLimit,
|
|
3848
|
+
budget === void 0 || held === null ? void 0 : {
|
|
3849
|
+
arrive: (bytes) => held.arrive(bytes),
|
|
3850
|
+
time: { clock: budget.clock, stallMs: budget.limits.heldBodyStallMs, arrivalMs: budget.limits.heldBodyArrivalMs }
|
|
3851
|
+
}
|
|
3852
|
+
);
|
|
3853
|
+
if (bodyRead.outcome !== "ok") {
|
|
3854
|
+
const named = route.slug === RESOLVE_BY_HOST ? request.headers.get("x-wirl-target") : null;
|
|
3855
|
+
if (route.slug === RESOLVE_BY_HOST) slug = "";
|
|
3856
|
+
if (named) path = egressAuditPath(named);
|
|
3857
|
+
const host2 = named ? safeHost(named) : "";
|
|
3858
|
+
held?.release();
|
|
3859
|
+
if (bodyRead.outcome === "too_large") {
|
|
3860
|
+
await record("body_too_large", 413, host2);
|
|
3861
|
+
return refuse2(
|
|
3862
|
+
413,
|
|
3863
|
+
"body_too_large",
|
|
3864
|
+
`This call's body is more than ${bodyLimit} bytes, the most a call may send, so wirl sent nothing.`
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
if (bodyRead.outcome === "full") {
|
|
3868
|
+
const limits = budget?.limits ?? BROKER_LIMITS;
|
|
3869
|
+
const refusal = heldBodyRefusal(limits, held?.refusal() ?? "all");
|
|
3870
|
+
await record(refusal.error, 503, host2);
|
|
3871
|
+
return refuse2(503, refusal.error, refusal.message, { "retry-after": String(limits.heldBodyRetryAfterSeconds) });
|
|
3872
|
+
}
|
|
3873
|
+
if (bodyRead.outcome === "stalled" || bodyRead.outcome === "slow") {
|
|
3874
|
+
const cut2 = heldBodyCut(budget?.limits ?? BROKER_LIMITS, bodyRead.outcome);
|
|
3875
|
+
await record(cut2.error, 408, host2);
|
|
3876
|
+
return refuse2(408, cut2.error, cut2.message);
|
|
3877
|
+
}
|
|
3878
|
+
if (request.signal.aborted) return callerLeft(record, host2);
|
|
3879
|
+
await record("upstream_error", 502, host2);
|
|
3880
|
+
return refuse2(502, "upstream_error", bodyRead.error instanceof Error ? bodyRead.error.message : String(bodyRead.error));
|
|
3881
|
+
}
|
|
3882
|
+
requestBody = bodyRead.bytes;
|
|
3883
|
+
}
|
|
3407
3884
|
const connections = await deps.connectionsFor(appId, deploymentId);
|
|
3408
3885
|
let connection;
|
|
3409
3886
|
if (route.slug === RESOLVE_BY_HOST) {
|
|
@@ -3492,11 +3969,18 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3492
3969
|
headers.set(name, value);
|
|
3493
3970
|
});
|
|
3494
3971
|
attachCredential(headers, connection);
|
|
3972
|
+
const answers = deps.heldAnswers;
|
|
3973
|
+
if (answers !== void 0 && request.method !== "HEAD") {
|
|
3974
|
+
const short = answers.roomFor(appId, ownerId);
|
|
3975
|
+
if (short !== null) {
|
|
3976
|
+
const noRoom = heldAnswerNoRoom(answers.limits, short);
|
|
3977
|
+
await record(noRoom.error, 503, host);
|
|
3978
|
+
return refuse2(503, noRoom.error, noRoom.message, { "retry-after": String(answers.limits.heldAnswerRetryAfterSeconds) });
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3495
3981
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
3496
|
-
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
3497
3982
|
let upstream;
|
|
3498
3983
|
try {
|
|
3499
|
-
const requestBody = hasBody ? await request.arrayBuffer() : null;
|
|
3500
3984
|
if (pastFlowDeadline(origin, deps)) return deadlinePassed();
|
|
3501
3985
|
upstream = await doFetch(decision.ok ? decision.url : target, {
|
|
3502
3986
|
method: request.method,
|
|
@@ -3536,16 +4020,30 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3536
4020
|
return refuse2(502, "too_large", `The upstream announced ${declared} bytes, more than the ${limit} a call may return.`);
|
|
3537
4021
|
}
|
|
3538
4022
|
if (upstreamBody !== null && isStreamedAnswer(upstream.status, upstream.headers)) {
|
|
4023
|
+
const body2 = holding.body;
|
|
4024
|
+
holding.body = null;
|
|
3539
4025
|
return streamAnswer(upstream, upstreamBody, {
|
|
3540
4026
|
appId,
|
|
3541
4027
|
limit,
|
|
3542
4028
|
slots: deps.streamSlots ?? null,
|
|
3543
4029
|
safetyMs: deps.streamSafetyMs ?? BROKER_LIMITS.headersMs + BROKER_LIMITS.streamBodyMs,
|
|
4030
|
+
closing: closesWithEvent(upstream.headers),
|
|
4031
|
+
sent: deps.streamSent ?? null,
|
|
4032
|
+
sentMs: deps.streamSentMs ?? BROKER_LIMITS.cutEventSentMs,
|
|
3544
4033
|
signal: request.signal,
|
|
3545
|
-
record: (outcome, status) => record(outcome, status, safeHost(target))
|
|
4034
|
+
record: (outcome, status) => record(outcome, status, safeHost(target)),
|
|
4035
|
+
held: body2
|
|
3546
4036
|
});
|
|
3547
4037
|
}
|
|
3548
|
-
const
|
|
4038
|
+
const heldAnswer = answers?.open(appId, ownerId) ?? null;
|
|
4039
|
+
holding.answer = heldAnswer;
|
|
4040
|
+
const read = heldAnswer === null ? await readWithin(upstreamBody, limit) : await readWithin(upstreamBody, limit, { arrive: (bytes) => heldAnswer.arrive(bytes) });
|
|
4041
|
+
if (read.outcome !== "ok") heldAnswer?.release();
|
|
4042
|
+
if (read.outcome === "full") {
|
|
4043
|
+
const refusal = heldAnswerRefusal(answers?.limits ?? BROKER_LIMITS, heldAnswer?.refusal() ?? "all");
|
|
4044
|
+
await record(refusal.error, 502, safeHost(target));
|
|
4045
|
+
return refuse2(502, refusal.error, refusal.message);
|
|
4046
|
+
}
|
|
3549
4047
|
if (read.outcome === "error") {
|
|
3550
4048
|
if (request.signal.aborted) return callerLeft(record, safeHost(target));
|
|
3551
4049
|
await record("upstream_error", 502, safeHost(target));
|
|
@@ -3560,7 +4058,76 @@ async function handleBrokerRequest(request, deps) {
|
|
|
3560
4058
|
const out2 = answerHeaders(upstream.headers);
|
|
3561
4059
|
const empty = upstream.status === 204 || upstream.status === 304;
|
|
3562
4060
|
if (!empty) out2.set("content-length", String(body.byteLength));
|
|
3563
|
-
|
|
4061
|
+
if (answers === void 0 || heldAnswer === null || empty || body.byteLength === 0) {
|
|
4062
|
+
return new Response(empty ? null : body, { status: upstream.status, headers: out2 });
|
|
4063
|
+
}
|
|
4064
|
+
const exact = body.byteLength < body.buffer.byteLength ? body.slice() : body;
|
|
4065
|
+
const handed = handOut(exact, heldAnswer, request.signal, answers.clock, answers.limits.heldAnswerHandOutMs);
|
|
4066
|
+
let answer;
|
|
4067
|
+
try {
|
|
4068
|
+
answer = new Response(handed, { status: upstream.status, headers: out2 });
|
|
4069
|
+
} catch (error) {
|
|
4070
|
+
await handed.cancel().catch(() => {
|
|
4071
|
+
});
|
|
4072
|
+
throw error;
|
|
4073
|
+
}
|
|
4074
|
+
holding.answer = null;
|
|
4075
|
+
return answer;
|
|
4076
|
+
}
|
|
4077
|
+
async function ownerOfApp(deps, appId) {
|
|
4078
|
+
if (deps.ownerOf === void 0 || deps.heldBodies === void 0 && deps.heldAnswers === void 0) return null;
|
|
4079
|
+
const owner = await deps.ownerOf(appId);
|
|
4080
|
+
return typeof owner === "string" && owner.length > 0 ? owner : null;
|
|
4081
|
+
}
|
|
4082
|
+
function handOut(bytes, held, signal, clock, handOutMs) {
|
|
4083
|
+
let left = bytes;
|
|
4084
|
+
let stopWait = () => {
|
|
4085
|
+
};
|
|
4086
|
+
let appLeft = () => {
|
|
4087
|
+
};
|
|
4088
|
+
const finish = () => {
|
|
4089
|
+
if (left === null) return false;
|
|
4090
|
+
left = null;
|
|
4091
|
+
stopWait();
|
|
4092
|
+
signal.removeEventListener("abort", appLeft);
|
|
4093
|
+
held.release();
|
|
4094
|
+
return true;
|
|
4095
|
+
};
|
|
4096
|
+
return new ReadableStream(
|
|
4097
|
+
{
|
|
4098
|
+
start(controller) {
|
|
4099
|
+
const wait = clock.wait(handOutMs);
|
|
4100
|
+
stopWait = () => wait.cancel();
|
|
4101
|
+
void wait.done.then(() => {
|
|
4102
|
+
if (!finish()) return;
|
|
4103
|
+
const cut2 = new Error(
|
|
4104
|
+
`The answer was not all taken within ${String(handOutMs / 1e3)} seconds of being handed back, the longest wirl holds one, and was cut there.`
|
|
4105
|
+
);
|
|
4106
|
+
cut2.stack = `Error: ${cut2.message}`;
|
|
4107
|
+
controller.error(cut2);
|
|
4108
|
+
});
|
|
4109
|
+
appLeft = () => {
|
|
4110
|
+
if (finish()) controller.close();
|
|
4111
|
+
};
|
|
4112
|
+
if (signal.aborted) appLeft();
|
|
4113
|
+
else signal.addEventListener("abort", appLeft, { once: true });
|
|
4114
|
+
},
|
|
4115
|
+
pull(controller) {
|
|
4116
|
+
if (left === null) return;
|
|
4117
|
+
const slice = left.slice(0, HAND_OUT_BYTES);
|
|
4118
|
+
left = left.subarray(slice.byteLength);
|
|
4119
|
+
controller.enqueue(slice);
|
|
4120
|
+
if (left.byteLength > 0) return;
|
|
4121
|
+
finish();
|
|
4122
|
+
controller.close();
|
|
4123
|
+
},
|
|
4124
|
+
cancel() {
|
|
4125
|
+
finish();
|
|
4126
|
+
}
|
|
4127
|
+
},
|
|
4128
|
+
// Nothing is cut ahead of the app: the server's pace is the app's.
|
|
4129
|
+
{ highWaterMark: 0 }
|
|
4130
|
+
);
|
|
3564
4131
|
}
|
|
3565
4132
|
function egressAuditPath(target) {
|
|
3566
4133
|
let pathname;
|
|
@@ -3614,13 +4181,14 @@ function declaredLength(headers) {
|
|
|
3614
4181
|
return Number(raw.trim());
|
|
3615
4182
|
}
|
|
3616
4183
|
async function streamAnswer(upstream, body, context) {
|
|
3617
|
-
|
|
3618
|
-
|
|
4184
|
+
const giveBack = () => context.held?.release();
|
|
4185
|
+
let release = giveBack;
|
|
3619
4186
|
if (context.slots !== null) {
|
|
3620
4187
|
const slot = context.slots.acquire(context.appId);
|
|
3621
4188
|
if (slot === null) {
|
|
3622
4189
|
await body.cancel().catch(() => {
|
|
3623
4190
|
});
|
|
4191
|
+
giveBack();
|
|
3624
4192
|
await context.record("busy", 429);
|
|
3625
4193
|
return refuse2(
|
|
3626
4194
|
429,
|
|
@@ -3628,25 +4196,60 @@ async function streamAnswer(upstream, body, context) {
|
|
|
3628
4196
|
`This app already has ${context.slots.perApp} streamed answers open, the most it may hold at once; try again when one of them has finished.`
|
|
3629
4197
|
);
|
|
3630
4198
|
}
|
|
3631
|
-
release =
|
|
4199
|
+
release = () => {
|
|
4200
|
+
slot();
|
|
4201
|
+
giveBack();
|
|
4202
|
+
};
|
|
3632
4203
|
}
|
|
3633
4204
|
try {
|
|
3634
4205
|
await context.record("allowed", upstream.status);
|
|
3635
4206
|
} catch (error) {
|
|
4207
|
+
await body.cancel().catch(() => {
|
|
4208
|
+
});
|
|
3636
4209
|
release();
|
|
4210
|
+
throw error;
|
|
4211
|
+
}
|
|
4212
|
+
try {
|
|
4213
|
+
return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal, context.closing, context.sent, context.sentMs), {
|
|
4214
|
+
status: upstream.status,
|
|
4215
|
+
headers: answerHeaders(upstream.headers)
|
|
4216
|
+
});
|
|
4217
|
+
} catch (error) {
|
|
3637
4218
|
await body.cancel().catch(() => {
|
|
3638
4219
|
});
|
|
4220
|
+
release();
|
|
3639
4221
|
throw error;
|
|
3640
4222
|
}
|
|
3641
|
-
return new Response(passOn(body, context.limit, release, context.safetyMs, context.signal), {
|
|
3642
|
-
status: upstream.status,
|
|
3643
|
-
headers: answerHeaders(upstream.headers)
|
|
3644
|
-
});
|
|
3645
4223
|
}
|
|
3646
|
-
function
|
|
4224
|
+
async function eventSent(sent, ms) {
|
|
4225
|
+
if (sent === null) {
|
|
4226
|
+
await new Promise((resolve7) => setTimeout(resolve7, 0));
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
const stop = new AbortController();
|
|
4230
|
+
let bound;
|
|
4231
|
+
try {
|
|
4232
|
+
await Promise.race([
|
|
4233
|
+
sent(stop.signal),
|
|
4234
|
+
new Promise((resolve7) => {
|
|
4235
|
+
bound = setTimeout(resolve7, ms);
|
|
4236
|
+
bound.unref?.();
|
|
4237
|
+
})
|
|
4238
|
+
]);
|
|
4239
|
+
} catch {
|
|
4240
|
+
} finally {
|
|
4241
|
+
clearTimeout(bound);
|
|
4242
|
+
stop.abort();
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
function passOn(body, limit, release, safetyMs, signal, closing, sent, sentMs) {
|
|
3647
4246
|
const reader = body.getReader();
|
|
4247
|
+
const says = closing && limit > CUT_EVENT_RESERVE_BYTES;
|
|
4248
|
+
const room = says ? limit - CUT_EVENT_RESERVE_BYTES : limit;
|
|
3648
4249
|
let total = 0;
|
|
3649
4250
|
let over = false;
|
|
4251
|
+
let parting = null;
|
|
4252
|
+
let unread;
|
|
3650
4253
|
let safety;
|
|
3651
4254
|
let appLeft = () => {
|
|
3652
4255
|
};
|
|
@@ -3658,17 +4261,40 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3658
4261
|
release();
|
|
3659
4262
|
return true;
|
|
3660
4263
|
};
|
|
3661
|
-
const cut2 = (controller, error) => {
|
|
4264
|
+
const cut2 = (controller, error, why) => {
|
|
3662
4265
|
if (!finish()) return;
|
|
3663
|
-
controller.error(error);
|
|
3664
4266
|
reader.cancel(error).catch(() => {
|
|
3665
4267
|
});
|
|
4268
|
+
if (!says) {
|
|
4269
|
+
controller.error(error);
|
|
4270
|
+
return;
|
|
4271
|
+
}
|
|
4272
|
+
try {
|
|
4273
|
+
controller.enqueue(streamCutEvent(why));
|
|
4274
|
+
} catch {
|
|
4275
|
+
controller.error(error);
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
parting = error;
|
|
4279
|
+
unread = setTimeout(() => {
|
|
4280
|
+
if (parting === null) return;
|
|
4281
|
+
parting = null;
|
|
4282
|
+
try {
|
|
4283
|
+
controller.error(error);
|
|
4284
|
+
} catch {
|
|
4285
|
+
}
|
|
4286
|
+
}, sentMs);
|
|
4287
|
+
unread.unref?.();
|
|
3666
4288
|
};
|
|
3667
4289
|
return new ReadableStream(
|
|
3668
4290
|
{
|
|
3669
4291
|
start(controller) {
|
|
3670
4292
|
const timer = setTimeout(() => {
|
|
3671
|
-
cut2(
|
|
4293
|
+
cut2(
|
|
4294
|
+
controller,
|
|
4295
|
+
new Error(`The upstream's answer was still open after ${safetyMs} ms, the longest the broker holds one, and was cut there.`),
|
|
4296
|
+
{ cause: "held", ms: safetyMs }
|
|
4297
|
+
);
|
|
3672
4298
|
}, safetyMs);
|
|
3673
4299
|
timer.unref?.();
|
|
3674
4300
|
safety = timer;
|
|
@@ -3682,11 +4308,21 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3682
4308
|
else signal.addEventListener("abort", appLeft, { once: true });
|
|
3683
4309
|
},
|
|
3684
4310
|
async pull(controller) {
|
|
4311
|
+
if (parting !== null) {
|
|
4312
|
+
const error = parting;
|
|
4313
|
+
parting = null;
|
|
4314
|
+
clearTimeout(unread);
|
|
4315
|
+
await eventSent(sent, sentMs);
|
|
4316
|
+
controller.error(error);
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
3685
4319
|
let next;
|
|
3686
4320
|
try {
|
|
3687
4321
|
next = await reader.read();
|
|
3688
4322
|
} catch (error) {
|
|
3689
|
-
|
|
4323
|
+
const bound = error instanceof PinnedFetchError ? error.streamBound : void 0;
|
|
4324
|
+
if (bound !== void 0) cut2(controller, error, { cause: bound.kind, ms: bound.ms });
|
|
4325
|
+
else if (finish()) controller.error(error);
|
|
3690
4326
|
return;
|
|
3691
4327
|
}
|
|
3692
4328
|
if (over) return;
|
|
@@ -3696,14 +4332,21 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3696
4332
|
return;
|
|
3697
4333
|
}
|
|
3698
4334
|
total += next.value.byteLength;
|
|
3699
|
-
if (total >
|
|
3700
|
-
cut2(
|
|
4335
|
+
if (total > room) {
|
|
4336
|
+
cut2(
|
|
4337
|
+
controller,
|
|
4338
|
+
new Error(
|
|
4339
|
+
says ? `The upstream's answer reached the ${limit} bytes a call may return, counting the event that closes it, and was cut there.` : `The upstream's answer passed ${limit} bytes, the most a call may return, and was cut there.`
|
|
4340
|
+
),
|
|
4341
|
+
{ cause: "size", bytes: limit }
|
|
4342
|
+
);
|
|
3701
4343
|
return;
|
|
3702
4344
|
}
|
|
3703
4345
|
controller.enqueue(next.value);
|
|
3704
4346
|
},
|
|
3705
4347
|
cancel(reason) {
|
|
3706
4348
|
finish();
|
|
4349
|
+
clearTimeout(unread);
|
|
3707
4350
|
return reader.cancel(reason).catch(() => {
|
|
3708
4351
|
});
|
|
3709
4352
|
}
|
|
@@ -3712,37 +4355,62 @@ function passOn(body, limit, release, safetyMs, signal) {
|
|
|
3712
4355
|
{ highWaterMark: 0 }
|
|
3713
4356
|
);
|
|
3714
4357
|
}
|
|
3715
|
-
async function readWithin(body, limit) {
|
|
3716
|
-
if (body === null) return { outcome: "ok", bytes: new
|
|
3717
|
-
|
|
3718
|
-
|
|
4358
|
+
async function readWithin(body, limit, bounds) {
|
|
4359
|
+
if (body === null) return { outcome: "ok", bytes: new Uint8Array(0) };
|
|
4360
|
+
let reader;
|
|
4361
|
+
let held = new Uint8Array(0);
|
|
3719
4362
|
let total = 0;
|
|
4363
|
+
const time = bounds?.time;
|
|
4364
|
+
const arrival = time?.clock.wait(time.arrivalMs);
|
|
4365
|
+
const slow = arrival?.done.then(() => SLOW);
|
|
3720
4366
|
try {
|
|
4367
|
+
reader = body.getReader();
|
|
3721
4368
|
for (; ; ) {
|
|
3722
|
-
const next = await reader
|
|
4369
|
+
const next = await nextWithin(reader, time, slow);
|
|
4370
|
+
if (next === STALLED || next === SLOW) {
|
|
4371
|
+
await reader.cancel().catch(() => {
|
|
4372
|
+
});
|
|
4373
|
+
return { outcome: next === STALLED ? "stalled" : "slow" };
|
|
4374
|
+
}
|
|
3723
4375
|
if (next.done) break;
|
|
3724
4376
|
const chunk = next.value;
|
|
3725
4377
|
if (chunk === void 0) continue;
|
|
3726
|
-
total
|
|
3727
|
-
if (
|
|
4378
|
+
const needed = total + chunk.byteLength;
|
|
4379
|
+
if (needed > limit) {
|
|
3728
4380
|
await reader.cancel().catch(() => {
|
|
3729
4381
|
});
|
|
3730
4382
|
return { outcome: "too_large" };
|
|
3731
4383
|
}
|
|
3732
|
-
|
|
4384
|
+
if (bounds !== void 0 && !bounds.arrive(chunk.byteLength)) {
|
|
4385
|
+
await reader.cancel().catch(() => {
|
|
4386
|
+
});
|
|
4387
|
+
return { outcome: "full" };
|
|
4388
|
+
}
|
|
4389
|
+
if (needed > held.byteLength) {
|
|
4390
|
+
const grown = new Uint8Array(Math.min(limit, Math.max(needed, held.byteLength * 2, FIRST_HOLD_BYTES)));
|
|
4391
|
+
grown.set(held.subarray(0, total));
|
|
4392
|
+
held = grown;
|
|
4393
|
+
}
|
|
4394
|
+
held.set(chunk, total);
|
|
4395
|
+
total = needed;
|
|
3733
4396
|
}
|
|
3734
4397
|
} catch (error) {
|
|
3735
|
-
await reader
|
|
4398
|
+
await reader?.cancel().catch(() => {
|
|
3736
4399
|
});
|
|
3737
4400
|
return { outcome: "error", error };
|
|
4401
|
+
} finally {
|
|
4402
|
+
arrival?.cancel();
|
|
3738
4403
|
}
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
4404
|
+
return { outcome: "ok", bytes: held.subarray(0, total) };
|
|
4405
|
+
}
|
|
4406
|
+
async function nextWithin(reader, time, slow) {
|
|
4407
|
+
if (time === void 0 || slow === void 0) return reader.read();
|
|
4408
|
+
const stall = time.clock.wait(time.stallMs);
|
|
4409
|
+
try {
|
|
4410
|
+
return await Promise.race([reader.read(), stall.done.then(() => STALLED), slow]);
|
|
4411
|
+
} finally {
|
|
4412
|
+
stall.cancel();
|
|
3744
4413
|
}
|
|
3745
|
-
return { outcome: "ok", bytes: joined2.buffer };
|
|
3746
4414
|
}
|
|
3747
4415
|
function credentialShape(kind) {
|
|
3748
4416
|
return Object.hasOwn(CREDENTIAL_SHAPES, kind) ? CREDENTIAL_SHAPES[kind] : null;
|
|
@@ -3792,19 +4460,21 @@ function readBearer(request) {
|
|
|
3792
4460
|
const value = header.slice(7).trim();
|
|
3793
4461
|
return value.length > 0 ? value : null;
|
|
3794
4462
|
}
|
|
3795
|
-
function refuse2(status, reason, message) {
|
|
4463
|
+
function refuse2(status, reason, message, extra = {}) {
|
|
3796
4464
|
return new Response(JSON.stringify({ error: reason, message }, null, 2), {
|
|
3797
4465
|
status,
|
|
3798
4466
|
headers: new Headers({
|
|
3799
4467
|
"content-type": "application/json; charset=utf-8",
|
|
3800
|
-
"cache-control": "no-store"
|
|
4468
|
+
"cache-control": "no-store",
|
|
4469
|
+
...extra
|
|
3801
4470
|
})
|
|
3802
4471
|
});
|
|
3803
4472
|
}
|
|
3804
|
-
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, AUDIT_PATH_CHARS, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
4473
|
+
var RESOLVE_BY_HOST, STRIPPED_PREFIXES, STRIPPED, HOP_PREFIXES, HOP_HEADERS, isHopHeader, HAND_OUT_BYTES, AUDIT_PATH_CHARS, STALLED, SLOW, FIRST_HOLD_BYTES, CREDENTIAL_SHAPES, isHttpKind, safeHost;
|
|
3805
4474
|
var init_broker = __esm({
|
|
3806
4475
|
"packages/core/src/broker.ts"() {
|
|
3807
4476
|
"use strict";
|
|
4477
|
+
init_broker_bodies();
|
|
3808
4478
|
init_broker_stream();
|
|
3809
4479
|
init_broker_token();
|
|
3810
4480
|
init_egress();
|
|
@@ -3856,7 +4526,11 @@ var init_broker = __esm({
|
|
|
3856
4526
|
"malicious-uploads-detection"
|
|
3857
4527
|
]);
|
|
3858
4528
|
isHopHeader = (lower) => HOP_HEADERS.has(lower) || HOP_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
4529
|
+
HAND_OUT_BYTES = 64 * 1024;
|
|
3859
4530
|
AUDIT_PATH_CHARS = 200;
|
|
4531
|
+
STALLED = /* @__PURE__ */ Symbol("stalled");
|
|
4532
|
+
SLOW = /* @__PURE__ */ Symbol("slow");
|
|
4533
|
+
FIRST_HOLD_BYTES = 16 * 1024;
|
|
3860
4534
|
CREDENTIAL_SHAPES = Object.freeze({
|
|
3861
4535
|
http_bearer: Object.freeze({ sends: "bearer", fields: Object.freeze(["token"]) }),
|
|
3862
4536
|
http_header: Object.freeze({ sends: "headers", fields: Object.freeze([]) }),
|
|
@@ -3972,20 +4646,21 @@ arrives: pass \`response.body\` straight on. There are no WebSockets. A front en
|
|
|
3972
4646
|
\`"static": "dist"\` serves that folder's files at their own paths (\`/\` is its \`index.html\`) and the module
|
|
3973
4647
|
answers everything else; add \`"spa": true\` for a client-side router. Nothing outside that folder is served.
|
|
3974
4648
|
|
|
3975
|
-
Preview before you deploy: \`wirl preview\`
|
|
3976
|
-
|
|
3977
|
-
\`wirl_preview_fetch\` tool) shows you what it serves.
|
|
4649
|
+
Preview before you deploy: \`wirl preview\` gives a folder its own address and database (at first only what
|
|
4650
|
+
\`migrations/\` makes). \`wirl fetch <path> --preview <name>\` or \`wirl_preview_fetch\` shows what it answers. Every request a preview's module gets names the preview in \`x-wirl-preview\`, and wirl marks its pages as previews.
|
|
3978
4651
|
|
|
3979
4652
|
npm packages work: install them in the project and the deploy bundles them in. Nothing is installed on the
|
|
3980
4653
|
server: native code will not run. Node built-ins work (\`node:crypto\`, \`node:path\`, ...; \`Buffer\` and
|
|
3981
4654
|
\`process\` are global), but what \`node:fs\` writes is gone by the next request: use \`env.DB\`.
|
|
3982
4655
|
|
|
3983
4656
|
\`env\` holds \`DB\` and nothing else. \`env.DB\` is Cloudflare D1: prepared statements with \`.bind()\`, at most
|
|
3984
|
-
100 bound parameters a statement. \`exec()\` runs one statement per line
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
4657
|
+
100 bound parameters a statement. \`exec()\` runs one statement per line, as does each file in \`migrations/\`,
|
|
4658
|
+
which wirl applies in order to a preview and before going live; a failing one fails the deploy. Never edit
|
|
4659
|
+
an applied file. Dropping or renaming a table or column needs \`"migrations": { "destructive": [...] }\`, its
|
|
4660
|
+
own live deploy and a person's yes. Declare a host under \`connections\`, then \`fetch\` the vendor's real URL:
|
|
4661
|
+
wirl attaches the key. An undeclared host is refused; a public API needing no key uses \`"kind": "none"\`.
|
|
4662
|
+
Declaring one deploys before anyone approves it; the deploy says per connection whether it is usable now or
|
|
4663
|
+
waiting on an admin. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream.
|
|
3989
4664
|
|
|
3990
4665
|
Six headers say who is asking: \`x-wirl-user-id\`, \`x-wirl-user-email\`, \`x-wirl-user-name\`,
|
|
3991
4666
|
\`x-wirl-role\`, \`x-wirl-app-id\`, \`x-wirl-org\`. Read the name with
|
|
@@ -3995,10 +4670,9 @@ Schedules POST to \`/__wirl/schedule/<name>\`; a run gets 5 minutes unless that
|
|
|
3995
4670
|
says otherwise, and at most 15. A \`public_paths\` path takes requests with no sign-in.
|
|
3996
4671
|
Work that outlives one request (waiting for a person, trying tomorrow, a long loop) is a flow: see Flows.
|
|
3997
4672
|
|
|
3998
|
-
A new app defaults to org-wide visibility when its org has a company email domain,
|
|
3999
|
-
|
|
4000
|
-
Sharing by email adds one named person
|
|
4001
|
-
sets the level. Public is only ever a person's click on the app's Access page.
|
|
4673
|
+
A new app defaults to org-wide visibility when its org has a company email domain, and to private
|
|
4674
|
+
otherwise, including one made with \`wirl orgs create\`; \`wirl visibility private|org\` sets the level.
|
|
4675
|
+
Sharing by email adds one named person. Public is only ever a person's click on the app's Access page.
|
|
4002
4676
|
|
|
4003
4677
|
Porting an existing project: route it through the \`fetch\` handler, replace ORM calls with
|
|
4004
4678
|
\`env.DB.prepare(...).bind(...)\`, turn timers into \`schedules\`, and move keys into \`connections\`. Keep the
|
|
@@ -4030,18 +4704,66 @@ early, repeated or late one gets 409, or 404 once the run ends; check \`x-wirl-u
|
|
|
4030
4704
|
}
|
|
4031
4705
|
});
|
|
4032
4706
|
|
|
4707
|
+
// packages/core/src/version.ts
|
|
4708
|
+
function wirlVersion() {
|
|
4709
|
+
return BUILT_VERSION ?? WORKSPACE_VERSION;
|
|
4710
|
+
}
|
|
4711
|
+
var WORKSPACE_VERSION, BUILT_VERSION;
|
|
4712
|
+
var init_version = __esm({
|
|
4713
|
+
"packages/core/src/version.ts"() {
|
|
4714
|
+
"use strict";
|
|
4715
|
+
WORKSPACE_VERSION = "0.0.0";
|
|
4716
|
+
BUILT_VERSION = true ? "0.2.2" : null;
|
|
4717
|
+
}
|
|
4718
|
+
});
|
|
4719
|
+
|
|
4033
4720
|
// packages/core/src/install.ts
|
|
4721
|
+
function fallbackWhen(controlPlane = "this control plane") {
|
|
4722
|
+
return `If your npm registry does not carry wirl, or to run exactly the build ${controlPlane} serves,`;
|
|
4723
|
+
}
|
|
4724
|
+
function namedControlPlane(controlOrigin) {
|
|
4725
|
+
return `the wirl control plane (${controlOrigin.replace(/^https?:\/\//, "").replace(/\/+$/, "")})`;
|
|
4726
|
+
}
|
|
4034
4727
|
function installFor(controlOrigin) {
|
|
4035
|
-
if (NPM_PUBLISHED) return { cli: "wirl", mcp: "@wirl/mcp" };
|
|
4036
4728
|
const origin = controlOrigin.replace(/\/+$/, "");
|
|
4037
|
-
|
|
4729
|
+
const tarball = { cli: `${origin}/cli/wirl.tgz`, mcp: `${origin}/cli/wirl-mcp.tgz` };
|
|
4730
|
+
if (NPM_PUBLISHED) return { cli: "wirl", mcp: "@wirl/mcp", tarball };
|
|
4731
|
+
return { ...tarball, tarball };
|
|
4038
4732
|
}
|
|
4039
|
-
var NPM_PUBLISHED, WIRL_BIN;
|
|
4733
|
+
var NPM_PUBLISHED, WIRL_BIN, FALLBACK_WHEN, hasFallback;
|
|
4040
4734
|
var init_install = __esm({
|
|
4041
4735
|
"packages/core/src/install.ts"() {
|
|
4042
4736
|
"use strict";
|
|
4043
|
-
NPM_PUBLISHED =
|
|
4737
|
+
NPM_PUBLISHED = true;
|
|
4044
4738
|
WIRL_BIN = NPM_PUBLISHED ? "npx wirl" : "wirl";
|
|
4739
|
+
FALLBACK_WHEN = fallbackWhen();
|
|
4740
|
+
hasFallback = (install) => install.cli !== install.tarball.cli;
|
|
4741
|
+
}
|
|
4742
|
+
});
|
|
4743
|
+
|
|
4744
|
+
// packages/core/src/share-role.ts
|
|
4745
|
+
function readShareRole(value) {
|
|
4746
|
+
if (value === void 0 || value === null) return { ok: true, role: void 0 };
|
|
4747
|
+
if (value === AppRole.VIEWER || value === AppRole.EDITOR) return { ok: true, role: value };
|
|
4748
|
+
return { ok: false, message: SHARE_ROLE_REFUSAL };
|
|
4749
|
+
}
|
|
4750
|
+
var SHARE_ROLE_REFUSAL;
|
|
4751
|
+
var init_share_role = __esm({
|
|
4752
|
+
"packages/core/src/share-role.ts"() {
|
|
4753
|
+
"use strict";
|
|
4754
|
+
init_types();
|
|
4755
|
+
SHARE_ROLE_REFUSAL = "role must be viewer or editor.";
|
|
4756
|
+
}
|
|
4757
|
+
});
|
|
4758
|
+
|
|
4759
|
+
// packages/core/src/connect.ts
|
|
4760
|
+
var CLAUDE_CODE_SIGN_IN, CLAUDE_CODE_LOCAL_SIGN_IN;
|
|
4761
|
+
var init_connect = __esm({
|
|
4762
|
+
"packages/core/src/connect.ts"() {
|
|
4763
|
+
"use strict";
|
|
4764
|
+
init_install();
|
|
4765
|
+
CLAUDE_CODE_SIGN_IN = "`claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal.";
|
|
4766
|
+
CLAUDE_CODE_LOCAL_SIGN_IN = `The local server signs in the first time a wirl tool needs it: the agent calls \`wirl_login\`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with \`${WIRL_BIN} login\` skips this.`;
|
|
4045
4767
|
}
|
|
4046
4768
|
});
|
|
4047
4769
|
|
|
@@ -4265,6 +4987,66 @@ var init_porting = __esm({
|
|
|
4265
4987
|
}
|
|
4266
4988
|
});
|
|
4267
4989
|
|
|
4990
|
+
// packages/core/src/throttle-report.ts
|
|
4991
|
+
var THROTTLE_REPORT, EDGE_REFUSALS_NOTE;
|
|
4992
|
+
var init_throttle_report = __esm({
|
|
4993
|
+
"packages/core/src/throttle-report.ts"() {
|
|
4994
|
+
"use strict";
|
|
4995
|
+
init_public_path();
|
|
4996
|
+
THROTTLE_REPORT = {
|
|
4997
|
+
/**
|
|
4998
|
+
* One report per app a minute at most from each gate isolate, and what
|
|
4999
|
+
* one report counts spans a minute at most.
|
|
5000
|
+
*/
|
|
5001
|
+
windowMs: 6e4,
|
|
5002
|
+
/**
|
|
5003
|
+
* How long the refusal that opens a window waits for more before the
|
|
5004
|
+
* report goes, so a burst is one report with its count rather than a
|
|
5005
|
+
* report of its first refusal.
|
|
5006
|
+
*/
|
|
5007
|
+
gatherMs: 15e3,
|
|
5008
|
+
/**
|
|
5009
|
+
* The longest a refused request is asked to stay open, after its 429 has
|
|
5010
|
+
* gone, for the report to be sent. Cloudflare keeps a request's
|
|
5011
|
+
* `waitUntil` work for 30 seconds after the response, shared by all of
|
|
5012
|
+
* it, and the send itself has `sendTimeoutMs` of what is left.
|
|
5013
|
+
*/
|
|
5014
|
+
maxWaitMs: 2e4,
|
|
5015
|
+
sendTimeoutMs: 5e3,
|
|
5016
|
+
/**
|
|
5017
|
+
* Reports one request carries for windows no refusal could wait for
|
|
5018
|
+
* (`flushDue`). A request that was never refused must not queue its own
|
|
5019
|
+
* reads behind posts that are not its own: Cloudflare runs six of a
|
|
5020
|
+
* request's connections at once and queues the rest.
|
|
5021
|
+
*/
|
|
5022
|
+
flushPerRequest: 2,
|
|
5023
|
+
/** Apps one gate isolate counts for at once; a refusal for another app past this is not counted. */
|
|
5024
|
+
maxApps: 1e3,
|
|
5025
|
+
/** Lines in one report: each of an app's public paths, by each ceiling. */
|
|
5026
|
+
maxEntries: 2 * PUBLIC_PATH_LIMITS.perApp,
|
|
5027
|
+
/** A path is cut to this many characters, as `public.refused`'s is. */
|
|
5028
|
+
maxPathChars: 256,
|
|
5029
|
+
/**
|
|
5030
|
+
* The most of a report's body the control plane reads. A full report is
|
|
5031
|
+
* under 8 KiB: a path a request matched is ASCII, as a URL's path is.
|
|
5032
|
+
*/
|
|
5033
|
+
bodyBytes: 16384,
|
|
5034
|
+
/**
|
|
5035
|
+
* Reports the control plane reads for one app a minute, from every isolate
|
|
5036
|
+
* and location together. Of those, the first writes the app's one
|
|
5037
|
+
* `public.throttled` row for that minute and the rest write nothing.
|
|
5038
|
+
*/
|
|
5039
|
+
perAppPerMinute: 10,
|
|
5040
|
+
/** The org's Activity page summarises this many apps' latest `public.throttled` lines, a line an app, from the last `logsPageDays` days. */
|
|
5041
|
+
activityLines: 5,
|
|
5042
|
+
/** The app's Logs page shows its latest this many `public.throttled` lines, from the last `logsPageDays` days. */
|
|
5043
|
+
logsPageLines: 20,
|
|
5044
|
+
logsPageDays: 7
|
|
5045
|
+
};
|
|
5046
|
+
EDGE_REFUSALS_NOTE = "Requests wirl refuses before your app is called, such as a public path's 429 over its ceiling, are not invocations and never show here; anyone who can deploy the app sees those 429s counted as public.throttled on its Logs page in the dashboard.";
|
|
5047
|
+
}
|
|
5048
|
+
});
|
|
5049
|
+
|
|
4268
5050
|
// packages/core/src/supplied-bundle.ts
|
|
4269
5051
|
var SUPPLIED_BUNDLE_PATH, MAX_SUPPLIED_BUNDLE_BYTES;
|
|
4270
5052
|
var init_supplied_bundle = __esm({
|
|
@@ -4288,11 +5070,22 @@ var init_manifest_transport = __esm({
|
|
|
4288
5070
|
}
|
|
4289
5071
|
});
|
|
4290
5072
|
|
|
5073
|
+
// packages/core/src/preview.ts
|
|
5074
|
+
function versionNumbersSentence(listing) {
|
|
5075
|
+
return `A preview takes a version number from the same count as a deploy, and ${listing} never lists a preview, so a deploy's number can skip: if the newest version listed is v1 and this deploy is v4, v2 and v3 were previews.`;
|
|
5076
|
+
}
|
|
5077
|
+
var init_preview = __esm({
|
|
5078
|
+
"packages/core/src/preview.ts"() {
|
|
5079
|
+
"use strict";
|
|
5080
|
+
}
|
|
5081
|
+
});
|
|
5082
|
+
|
|
4291
5083
|
// packages/core/src/visit-token.ts
|
|
4292
|
-
var VISIT_QUERY_PARAM, VISIT_HEADER;
|
|
5084
|
+
var VISIT_TOKEN_TTL_SECONDS, VISIT_QUERY_PARAM, VISIT_HEADER;
|
|
4293
5085
|
var init_visit_token = __esm({
|
|
4294
5086
|
"packages/core/src/visit-token.ts"() {
|
|
4295
5087
|
"use strict";
|
|
5088
|
+
VISIT_TOKEN_TTL_SECONDS = 900;
|
|
4296
5089
|
VISIT_QUERY_PARAM = "__wirl_visit";
|
|
4297
5090
|
VISIT_HEADER = "x-wirl-visit";
|
|
4298
5091
|
}
|
|
@@ -4322,10 +5115,15 @@ var init_src = __esm({
|
|
|
4322
5115
|
init_retention();
|
|
4323
5116
|
init_identity();
|
|
4324
5117
|
init_contract();
|
|
5118
|
+
init_version();
|
|
4325
5119
|
init_install();
|
|
5120
|
+
init_share_role();
|
|
5121
|
+
init_connect();
|
|
4326
5122
|
init_porting();
|
|
5123
|
+
init_throttle_report();
|
|
4327
5124
|
init_supplied_bundle();
|
|
4328
5125
|
init_manifest_transport();
|
|
5126
|
+
init_preview();
|
|
4329
5127
|
init_visit_token();
|
|
4330
5128
|
}
|
|
4331
5129
|
});
|
|
@@ -5972,7 +6770,7 @@ import { constants as fsConstants, createReadStream } from "node:fs";
|
|
|
5972
6770
|
import { access, link, lstat as lstat2, mkdir as mkdir3, open, readFile as readFile3, readdir, rename as rename2, rm as rm3, stat as stat3, unlink, writeFile as writeFile2 } from "node:fs/promises";
|
|
5973
6771
|
import { homedir } from "node:os";
|
|
5974
6772
|
import { basename as basename2, dirname as dirname3, isAbsolute, join as join5, relative, resolve as resolve4, sep } from "node:path";
|
|
5975
|
-
import { create as tarCreate, extract as tarExtract } from "tar";
|
|
6773
|
+
import { create as tarCreate, extract as tarExtract, Parser as TarParser } from "tar";
|
|
5976
6774
|
import { gzipSync } from "node:zlib";
|
|
5977
6775
|
|
|
5978
6776
|
// packages/core/src/node/pinned-fetch.ts
|
|
@@ -6067,7 +6865,7 @@ function createPinnedFetch(options = {}) {
|
|
|
6067
6865
|
if (streamsBody !== void 0 && !streamsBody(status, answered)) return null;
|
|
6068
6866
|
clearTimeout(timer);
|
|
6069
6867
|
timer = setTimeout(() => {
|
|
6070
|
-
fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms
|
|
6868
|
+
fail(new PinnedFetchError("timeout", url.hostname, `${url.hostname} did not finish its answer within ${bodyTimeoutMs} ms.`, { kind: "time", ms: bodyTimeoutMs }));
|
|
6071
6869
|
}, bodyTimeoutMs);
|
|
6072
6870
|
return bodyIdleMs;
|
|
6073
6871
|
};
|
|
@@ -6204,13 +7002,17 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6204
7002
|
budget = idle ?? Math.max(MIN_ATTEMPT_MS, wire.remainingMs());
|
|
6205
7003
|
request.setTimeout(budget);
|
|
6206
7004
|
const bodyless = NO_BODY.has(status) || method.toUpperCase() === "HEAD";
|
|
7005
|
+
const answerOver = () => {
|
|
7006
|
+
wire.done();
|
|
7007
|
+
if (!request.writableFinished) request.destroy();
|
|
7008
|
+
};
|
|
6207
7009
|
if (bodyless) {
|
|
6208
7010
|
incoming.resume();
|
|
6209
7011
|
incoming.on("error", () => {
|
|
6210
7012
|
});
|
|
6211
|
-
|
|
7013
|
+
answerOver();
|
|
6212
7014
|
} else {
|
|
6213
|
-
incoming.once("close",
|
|
7015
|
+
incoming.once("close", answerOver);
|
|
6214
7016
|
}
|
|
6215
7017
|
resolve7(new Response(bodyless ? null : Readable.toWeb(incoming), { status, headers: out2 }));
|
|
6216
7018
|
} catch (error) {
|
|
@@ -6228,7 +7030,10 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6228
7030
|
const error = new PinnedFetchError(
|
|
6229
7031
|
"timeout",
|
|
6230
7032
|
url.hostname,
|
|
6231
|
-
streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}
|
|
7033
|
+
streaming ? `${url.hostname}'s answer stalled: nothing moved on it for ${budget} ms on ${address}, because the host sent nothing or nothing was read from it.` : `${url.hostname} did not answer within ${budget} ms on ${address}.`,
|
|
7034
|
+
// Which bound it was, for the broker to say what it cut a stream
|
|
7035
|
+
// for (OIM-837): only on a body on bounds of its own.
|
|
7036
|
+
streaming ? { kind: "idle", ms: budget } : void 0
|
|
6232
7037
|
);
|
|
6233
7038
|
if (delivered !== null) delivered.destroy(error);
|
|
6234
7039
|
else request.destroy(error);
|
|
@@ -6248,6 +7053,7 @@ function sendTo(wire, address, attempt, attemptMs) {
|
|
|
6248
7053
|
}
|
|
6249
7054
|
|
|
6250
7055
|
// packages/core/src/node/broker-fetch.ts
|
|
7056
|
+
init_broker_bodies();
|
|
6251
7057
|
init_broker_stream();
|
|
6252
7058
|
var BROKER_FETCH_OPTIONS = Object.freeze({
|
|
6253
7059
|
timeoutMs: BROKER_LIMITS.headersMs,
|
|
@@ -6258,8 +7064,36 @@ var BROKER_FETCH_OPTIONS = Object.freeze({
|
|
|
6258
7064
|
function createBrokerTransport(options = {}) {
|
|
6259
7065
|
return {
|
|
6260
7066
|
fetchImpl: options.fetchImpl ?? createPinnedFetch({ ...BROKER_FETCH_OPTIONS, ...options.pinning }),
|
|
6261
|
-
streamSlots: createStreamSlots()
|
|
7067
|
+
streamSlots: createStreamSlots(),
|
|
7068
|
+
heldBodies: createHeldBodies({ limits: options.heldBodyLimits, log: options.log }),
|
|
7069
|
+
heldAnswers: createHeldAnswers({ limits: options.heldAnswerLimits, log: options.log })
|
|
7070
|
+
};
|
|
7071
|
+
}
|
|
7072
|
+
|
|
7073
|
+
// packages/core/src/node/sent-on.ts
|
|
7074
|
+
var LOOK_EVERY_MS = 2;
|
|
7075
|
+
function sentOn(response) {
|
|
7076
|
+
if (response === void 0) return void 0;
|
|
7077
|
+
const nothingLeft = () => {
|
|
7078
|
+
const socket = response.socket;
|
|
7079
|
+
if (response.destroyed === true || socket === null || socket?.destroyed === true) return true;
|
|
7080
|
+
return (response.writableLength ?? socket?.writableLength ?? 0) === 0;
|
|
6262
7081
|
};
|
|
7082
|
+
return (stop) => new Promise((resolve7) => {
|
|
7083
|
+
let timer;
|
|
7084
|
+
const settle2 = () => {
|
|
7085
|
+
clearTimeout(timer);
|
|
7086
|
+
stop.removeEventListener("abort", settle2);
|
|
7087
|
+
resolve7();
|
|
7088
|
+
};
|
|
7089
|
+
const look = () => {
|
|
7090
|
+
if (stop.aborted || nothingLeft()) return settle2();
|
|
7091
|
+
timer = setTimeout(look, LOOK_EVERY_MS);
|
|
7092
|
+
timer.unref?.();
|
|
7093
|
+
};
|
|
7094
|
+
stop.addEventListener("abort", settle2, { once: true });
|
|
7095
|
+
look();
|
|
7096
|
+
});
|
|
6263
7097
|
}
|
|
6264
7098
|
|
|
6265
7099
|
// packages/core/src/node/dev-identity.ts
|
|
@@ -6277,10 +7111,9 @@ init_slug();
|
|
|
6277
7111
|
import { createHash as createHash2 } from "node:crypto";
|
|
6278
7112
|
var HASH_SUFFIX_LENGTH = 5;
|
|
6279
7113
|
function previewNameFor(raw, orgSlug, appSlug) {
|
|
6280
|
-
const
|
|
6281
|
-
if (!
|
|
6282
|
-
|
|
6283
|
-
}
|
|
7114
|
+
const made = previewSlugFor(raw);
|
|
7115
|
+
if (!made.ok) return { ok: false, message: made.message };
|
|
7116
|
+
const slug = made.slug;
|
|
6284
7117
|
const budget = MAX_HOST_LABEL - (appLabel(orgSlug, appSlug).length + HOST_SEPARATOR.length);
|
|
6285
7118
|
if (budget < 2) {
|
|
6286
7119
|
return {
|
|
@@ -6400,6 +7233,64 @@ function shellPath(value) {
|
|
|
6400
7233
|
return shellQuote(value);
|
|
6401
7234
|
}
|
|
6402
7235
|
|
|
7236
|
+
// packages/cli/src/skill.ts
|
|
7237
|
+
init_src();
|
|
7238
|
+
var SKILL_INTRO = `A new app is visible to everyone in the org when the org came from a
|
|
7239
|
+
company email domain; in any other org, including a personal one or one
|
|
7240
|
+
made with \`wirl orgs create\`, it starts private. Deploys are one
|
|
7241
|
+
command; sharing is by email; a third-party API is declared, never pasted.`;
|
|
7242
|
+
var SKILL_DOCS_LINE = "Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.";
|
|
7243
|
+
var SKILL_RULES = `1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
|
|
7244
|
+
2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
|
|
7245
|
+
3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
7246
|
+
4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
7247
|
+
5. **A file that can lose data waits for a person.** A deploy that would apply a destructive file (one wirl.json's \`"destructive"\` list must name) to the live database stops and names the page where a person approves each one. Show the person those pages, and deploy again once they say they approved. If \`${WIRL_BIN} deploy\` asks you to type a file's name, stop it and show the page instead, or deploy with \`--json\`, which never asks. Never type the name yourself or send the confirmation: that is the person's, and no flag skips it.`;
|
|
7248
|
+
var SKILL_MANIFEST_EXAMPLE = `\`\`\`json
|
|
7249
|
+
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
7250
|
+
\`\`\``;
|
|
7251
|
+
var SKILL_FILES_PARAGRAPH = "No blob storage: files go in `env.DB`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. `wirl dev` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.";
|
|
7252
|
+
var SKILL_STATIC_PARAGRAPH = 'A static folder maps a path to one file exactly: a path ending in `/` serves the `index.html` in that folder, `/about` never finds `about.html`, and a path that names no file goes to the module (with `"spa": true`, a page navigation gets `index.html` instead; in an app with no module, the top `index.html`, or a 404 for a missing file with an extension). Next.js runs only as a static export: `output: "export"` and `trailingSlash: true` in `next.config` make `next build` write `out/`, which goes under `"static"`; `next start` cannot run, and API routes move into the module.';
|
|
7253
|
+
function composeSkill(parts) {
|
|
7254
|
+
if (parts.description.trim() === "" || /[\r\n]/.test(parts.description)) {
|
|
7255
|
+
throw new Error("a skill description is one non-empty line");
|
|
7256
|
+
}
|
|
7257
|
+
return `---
|
|
7258
|
+
name: wirl
|
|
7259
|
+
description: ${parts.description}
|
|
7260
|
+
---
|
|
7261
|
+
|
|
7262
|
+
# wirl
|
|
7263
|
+
|
|
7264
|
+
${SKILL_INTRO}
|
|
7265
|
+
|
|
7266
|
+
${SKILL_DOCS_LINE}
|
|
7267
|
+
|
|
7268
|
+
## Commands
|
|
7269
|
+
|
|
7270
|
+
${parts.commands.trimEnd()}
|
|
7271
|
+
|
|
7272
|
+
## Rules
|
|
7273
|
+
|
|
7274
|
+
${SKILL_RULES}
|
|
7275
|
+
|
|
7276
|
+
## wirl.json
|
|
7277
|
+
|
|
7278
|
+
${SKILL_MANIFEST_EXAMPLE}
|
|
7279
|
+
|
|
7280
|
+
## What runs on wirl
|
|
7281
|
+
|
|
7282
|
+
${BUILD_CONTRACT}
|
|
7283
|
+
|
|
7284
|
+
${SKILL_FILES_PARAGRAPH}
|
|
7285
|
+
|
|
7286
|
+
${SKILL_STATIC_PARAGRAPH}
|
|
7287
|
+
|
|
7288
|
+
## Flows
|
|
7289
|
+
|
|
7290
|
+
${FLOWS_CONTRACT}
|
|
7291
|
+
`;
|
|
7292
|
+
}
|
|
7293
|
+
|
|
6403
7294
|
// packages/cli/src/core.ts
|
|
6404
7295
|
function credentialsPath(env = process.env) {
|
|
6405
7296
|
return env["WIRL_CREDENTIALS_PATH"] || join5(homedir(), ".config", "wirl", "credentials.json");
|
|
@@ -6460,7 +7351,7 @@ async function loadCredentials(env = process.env) {
|
|
|
6460
7351
|
}
|
|
6461
7352
|
} catch {
|
|
6462
7353
|
}
|
|
6463
|
-
throw new CliError("Not signed in.",
|
|
7354
|
+
throw new CliError("Not signed in.", `Run \`${WIRL_BIN} login\` first, or set WIRL_TOKEN.`, { code: "not_signed_in" });
|
|
6464
7355
|
}
|
|
6465
7356
|
async function saveCredentials(creds, env = process.env) {
|
|
6466
7357
|
const path = credentialsPath(env);
|
|
@@ -6521,11 +7412,11 @@ async function pollDeviceLogin(apiOrigin, login, options = {}) {
|
|
|
6521
7412
|
continue;
|
|
6522
7413
|
}
|
|
6523
7414
|
case "expired_token":
|
|
6524
|
-
throw new CliError("The sign-in code expired before it was approved.",
|
|
7415
|
+
throw new CliError("The sign-in code expired before it was approved.", `Run \`${WIRL_BIN} login\` again.`);
|
|
6525
7416
|
default:
|
|
6526
7417
|
throw new CliError(
|
|
6527
7418
|
typeof body["error"] === "string" ? `Sign-in failed: ${body["error"]}.` : `Sign-in failed (${response.status}).`,
|
|
6528
|
-
|
|
7419
|
+
`Run \`${WIRL_BIN} login\` again.`
|
|
6529
7420
|
);
|
|
6530
7421
|
}
|
|
6531
7422
|
}
|
|
@@ -6636,6 +7527,10 @@ var SERVER_TEMPLATE = [
|
|
|
6636
7527
|
" *",
|
|
6637
7528
|
" * Everything real happens here. The page is HTML this module returns. The",
|
|
6638
7529
|
" * data lives in env.DB, a SQLite database that belongs to this app alone.",
|
|
7530
|
+
" * Its tables are in migrations/0001_init.sql: wirl applies each new file in",
|
|
7531
|
+
" * migrations/ before a version goes live, to a preview's database when the",
|
|
7532
|
+
" * preview is put up, and to the local one when wirl dev starts. Put the",
|
|
7533
|
+
" * next change in migrations/0002_<what>.sql rather than editing that one.",
|
|
6639
7534
|
" * The person making the request is in headers wirl sets after verifying",
|
|
6640
7535
|
" * them, which the browser cannot forge. Any rule that matters, such as",
|
|
6641
7536
|
' * "you only see your own notes", is a line of code in this file: a check',
|
|
@@ -6647,18 +7542,8 @@ var SERVER_TEMPLATE = [
|
|
|
6647
7542
|
" * live in .wirl/keys.json beside this file, which git ignores.",
|
|
6648
7543
|
" */",
|
|
6649
7544
|
"",
|
|
6650
|
-
"// One line: exec runs one statement per line, as D1 does.",
|
|
6651
|
-
"const SCHEMA =",
|
|
6652
|
-
' "create table if not exists notes (" +',
|
|
6653
|
-
' " id integer primary key," +',
|
|
6654
|
-
' " user_id text not null," +',
|
|
6655
|
-
' " body text not null," +',
|
|
6656
|
-
` " created_at text not null default (datetime('now'))" +`,
|
|
6657
|
-
' ")";',
|
|
6658
|
-
"",
|
|
6659
7545
|
"export default {",
|
|
6660
7546
|
" async fetch(request, env) {",
|
|
6661
|
-
" await env.DB.exec(SCHEMA);",
|
|
6662
7547
|
" const url = new URL(request.url);",
|
|
6663
7548
|
"",
|
|
6664
7549
|
` // One line per request. Read it with ${WIRL_BIN} logs.`,
|
|
@@ -6741,56 +7626,54 @@ var SERVER_TEMPLATE = [
|
|
|
6741
7626
|
"}",
|
|
6742
7627
|
""
|
|
6743
7628
|
].join("\n");
|
|
7629
|
+
var INIT_MIGRATION = [
|
|
7630
|
+
"-- This app's tables. wirl applies each new file here, in order, before a version goes live.",
|
|
7631
|
+
"-- Make the next change in 0002_<what>.sql; never edit a file that has been deployed.",
|
|
7632
|
+
"create table notes (id integer primary key, user_id text not null, body text not null, created_at text not null default (datetime('now')))",
|
|
7633
|
+
""
|
|
7634
|
+
].join("\n");
|
|
7635
|
+
var INIT_MIGRATION_PATH = `${MIGRATIONS_DIR}/${ADOPTED_FILE}`;
|
|
7636
|
+
async function refuseInitMigrations(dir) {
|
|
7637
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
7638
|
+
if (entry.name.toLowerCase() !== MIGRATIONS_DIR) continue;
|
|
7639
|
+
if (entry.isSymbolicLink()) {
|
|
7640
|
+
throw new CliError(`${entry.name}/ is a symbolic link, and wirl writes into no links: make migrations/ a real folder, then run wirl init --server again.`);
|
|
7641
|
+
}
|
|
7642
|
+
if (entry.name !== MIGRATIONS_DIR) {
|
|
7643
|
+
throw new CliError(`${entry.name}/ is spelled differently from migrations/: wirl reads migrations/ in lowercase only. Rename it, then run wirl init --server again.`);
|
|
7644
|
+
}
|
|
7645
|
+
if (!entry.isDirectory()) {
|
|
7646
|
+
throw new CliError(
|
|
7647
|
+
`migrations is a file, and wirl init --server writes ${INIT_MIGRATION_PATH} into a folder of that name. Move it aside, then run wirl init --server again.`
|
|
7648
|
+
);
|
|
7649
|
+
}
|
|
7650
|
+
const names = await readdir(join5(dir, entry.name));
|
|
7651
|
+
if (names.some((name) => name.toLowerCase().endsWith(".sql"))) {
|
|
7652
|
+
throw new CliError(`migrations/ already holds .sql files; wirl init --server writes ${INIT_MIGRATION_PATH} into a folder with none.`);
|
|
7653
|
+
}
|
|
7654
|
+
}
|
|
7655
|
+
}
|
|
6744
7656
|
var SKILL_PATH = join5(".claude", "skills", "wirl", "SKILL.md");
|
|
6745
|
-
var
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
---
|
|
7657
|
+
var SKILL_ORIGIN = "https://app.wirl.dev";
|
|
7658
|
+
var SKILL_INSTALL = installFor(SKILL_ORIGIN);
|
|
7659
|
+
var SKILL_RUN = NPM_PUBLISHED ? `Run the CLI as \`${WIRL_BIN} <command>\`, which needs no install beyond Node 22.13 or later (in a terminal npm asks "Ok to proceed?" the first time: answer y); this file writes a command as \`wirl <command>\` for short anywhere but the list below.
|
|
6749
7660
|
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
Before you build, fetch https://docs.wirl.dev/llms-full.txt: every page of the docs as one Markdown file, so there is no need to read the site page by page.
|
|
6758
|
-
|
|
6759
|
-
## Commands
|
|
6760
|
-
|
|
6761
|
-
- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` opens the browser once to sign in. No HTTP MCP support? \`claude mcp add wirl -- npx -y ${installFor("https://app.wirl.dev").mcp}\` does the same, running locally. Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
|
|
7661
|
+
` : "";
|
|
7662
|
+
var SKILL_FALLBACK = hasFallback(SKILL_INSTALL) ? `- ${fallbackWhen(namedControlPlane(SKILL_ORIGIN))} install the CLI from the control plane instead with \`npm install -g ${SKILL_INSTALL.tarball.cli}\`, run plain \`wirl\` where this file says \`${WIRL_BIN}\`, and name \`${SKILL_INSTALL.tarball.mcp}\` in place of \`${SKILL_INSTALL.mcp}\`.
|
|
7663
|
+
` : "";
|
|
7664
|
+
var SKILL_MARKDOWN = composeSkill({
|
|
7665
|
+
description: `Deploy and share the internal app in this project with wirl (${WIRL_BIN}). Use when the user wants a tool, dashboard or prototype put somewhere colleagues can open it, shared with a named person, run locally the way it will run deployed, or connected to a third-party API; or wants work that outlives one request, such as waiting for a person to approve something or trying again tomorrow.`,
|
|
7666
|
+
commands: `${SKILL_RUN}- Connect first, if the agent supports MCP: \`claude mcp add --transport http wirl https://app.wirl.dev/mcp\` needs no install. ${CLAUDE_CODE_SIGN_IN} No HTTP MCP support? \`claude mcp add wirl -- npx -y ${SKILL_INSTALL.mcp}\` runs the same tools locally. ${CLAUDE_CODE_LOCAL_SIGN_IN} Neither available: \`${WIRL_BIN} login\` prints a link and a code; the person approves in the browser. Do this once, when any command says you are not signed in.
|
|
6762
7667
|
- \`${WIRL_BIN} deploy\` \u2014 deploys this directory. Prints the URL and who can open it.
|
|
6763
7668
|
- \`${WIRL_BIN} share someone@company.com\` \u2014 that person can open it. Add \`--role editor\` so they can deploy too.
|
|
6764
7669
|
- \`${WIRL_BIN} dev\` \u2014 runs the app here as it will run deployed, signed in as a stub user.
|
|
6765
7670
|
- \`${WIRL_BIN} pull <app>\` \u2014 fetches a deployed app's source to keep building.
|
|
6766
7671
|
- \`${WIRL_BIN} logs\` \u2014 what the deployed app printed, per request, newest first; \`--errors\` for failures only, \`--follow\` to keep watching, \`--cursor <n>\` for the page before a "More before this" line. Read this before changing code when something misbehaves.
|
|
7672
|
+
- \`${WIRL_BIN} migrations\` \u2014 what this app's database has applied, read only.
|
|
7673
|
+
- \`${WIRL_BIN} migrations adopt\` \u2014 once, for an app whose tables were made in code: writes \`migrations/0001_init.sql\` from the live database's shape.
|
|
6767
7674
|
- \`${WIRL_BIN} --help\` \u2014 everything else: orgs, members, connections, move, transfer, rollback, flows and runs.
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
1. **Never ask for or paste an API key.** Declare the connection in \`wirl.json\` and deploy. Needs no key at all (a public API)? Declare it with "kind": "none" and its hosts. If the key is missing, the deploy prints a link to the app's keys page; show the person that link. Locally, keys live in \`.wirl/keys.json\`, which git ignores. To try a vendor call locally without the key, map its connection to a server of your own in \`.wirl/mocks.json\`, like \`{ "anthropic": "http://127.0.0.1:4010" }\`: \`wirl dev\` then sends the calls to that connection's hosts there instead (\`wirl dev --help\` has the rest).
|
|
6772
|
-
2. **Call third-party APIs by their real URL** (\`fetch("https://api.hubspot.com/...")\`). wirl attaches the key on the way out, deployed and under \`wirl dev\` alike.
|
|
6773
|
-
3. If a command names several organisations and asks which, ask the person; pass \`--org\` or put \`"org"\` in \`wirl.json\`.
|
|
6774
|
-
4. To keep a file out of the deploy, list it in a \`.wirlignore\` at the project root (gitignore-style lines; no \`!\` negation).
|
|
6775
|
-
|
|
6776
|
-
## wirl.json
|
|
6777
|
-
|
|
6778
|
-
\`\`\`json
|
|
6779
|
-
{ "app": "crm-sync", "server": "server.mjs", "connections": [{ "slug": "hubspot", "hosts": ["api.hubspot.com"] }] }
|
|
6780
|
-
\`\`\`
|
|
6781
|
-
|
|
6782
|
-
## What runs on wirl
|
|
6783
|
-
|
|
6784
|
-
${BUILD_CONTRACT}
|
|
6785
|
-
|
|
6786
|
-
No blob storage: files go in \`env.DB\`, at most 2,000,000 bytes a value or row (the limit Cloudflare publishes for D1), so store a larger file in chunks, one row each, a few MB to a batch. \`wirl dev\` refuses a single bound value over 2,000,000 bytes, but a row's total, a value grown in SQL, or a batch bigger than one call to D1 carries can still pass locally and fail deployed.
|
|
6787
|
-
|
|
6788
|
-
A static folder maps a path to one file exactly: a path ending in \`/\` serves the \`index.html\` in that folder, \`/about\` never finds \`about.html\`, and a path that names no file goes to the module (with \`"spa": true\`, a page navigation gets \`index.html\` instead; in an app with no module, the top \`index.html\`, or a 404 for a missing file with an extension). Next.js runs only as a static export: \`output: "export"\` and \`trailingSlash: true\` in \`next.config\` make \`next build\` write \`out/\`, which goes under \`"static"\`; \`next start\` cannot run, and API routes move into the module.
|
|
6789
|
-
|
|
6790
|
-
## Flows
|
|
6791
|
-
|
|
6792
|
-
${FLOWS_CONTRACT}
|
|
6793
|
-
`;
|
|
7675
|
+
${SKILL_FALLBACK}`
|
|
7676
|
+
});
|
|
6794
7677
|
async function writeSkill(dir) {
|
|
6795
7678
|
const path = join5(dir, SKILL_PATH);
|
|
6796
7679
|
const existing = await readFile3(path, "utf8").catch(() => null);
|
|
@@ -6822,8 +7705,12 @@ async function scaffold(dir, options) {
|
|
|
6822
7705
|
} catch (error) {
|
|
6823
7706
|
if (error instanceof CliError) throw error;
|
|
6824
7707
|
}
|
|
7708
|
+
await refuseInitMigrations(dir);
|
|
6825
7709
|
await writeFile2(target, SERVER_TEMPLATE, "utf8");
|
|
6826
7710
|
written.push("server.mjs");
|
|
7711
|
+
await mkdir3(join5(dir, MIGRATIONS_DIR), { recursive: true });
|
|
7712
|
+
await writeFile2(join5(dir, MIGRATIONS_DIR, ADOPTED_FILE), INIT_MIGRATION, { encoding: "utf8", flag: "wx" });
|
|
7713
|
+
written.push(INIT_MIGRATION_PATH);
|
|
6827
7714
|
const pkg = join5(dir, "package.json");
|
|
6828
7715
|
if (await readFile3(pkg, "utf8").catch(() => null) === null) {
|
|
6829
7716
|
await writeFile2(pkg, `${JSON.stringify({ name: options.app, private: true, type: "module" }, null, 2)}
|
|
@@ -7113,7 +8000,7 @@ async function callApi(creds, path, options = {}) {
|
|
|
7113
8000
|
throw new CliError(body.message, void 0, refusal);
|
|
7114
8001
|
}
|
|
7115
8002
|
if (response.status === 401 || response.status === 403) {
|
|
7116
|
-
throw new CliError("Not signed in, or this token cannot do that.",
|
|
8003
|
+
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`, refusal);
|
|
7117
8004
|
}
|
|
7118
8005
|
if (response.status === 404) {
|
|
7119
8006
|
if (body.error !== void 0 && body.error !== "not_found" && body.message) throw new CliError(body.message, void 0, refusal);
|
|
@@ -7151,30 +8038,58 @@ async function callApiBytes(creds, path, options = {}) {
|
|
|
7151
8038
|
body = {};
|
|
7152
8039
|
}
|
|
7153
8040
|
if (response.status === 401 || response.status === 403) {
|
|
7154
|
-
throw new CliError("Not signed in, or this token cannot do that.",
|
|
8041
|
+
throw new CliError("Not signed in, or this token cannot do that.", `Try \`${WIRL_BIN} login\`.`);
|
|
7155
8042
|
}
|
|
7156
8043
|
if (response.status === 404) throw new CliError("Not found, or you do not have access to it.");
|
|
7157
8044
|
throw new CliError(body.message ?? body.error ?? `Request failed (${response.status}).`);
|
|
7158
8045
|
}
|
|
7159
8046
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
7160
8047
|
if (declared > MAX_PULL_BYTES) throw new CliError(`That is ${Math.round(declared / 1048576)} MB, more than wirl will pull.`);
|
|
7161
|
-
|
|
8048
|
+
let bytes;
|
|
8049
|
+
try {
|
|
8050
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
8051
|
+
} catch {
|
|
8052
|
+
throw new CliError(PULL_CUT, void 0, { code: "pull_cut" });
|
|
8053
|
+
}
|
|
7162
8054
|
if (bytes.byteLength > MAX_PULL_BYTES) throw new CliError("The download was larger than wirl will pull.");
|
|
7163
8055
|
return bytes;
|
|
7164
8056
|
}
|
|
8057
|
+
var PULL_CUT = "The pull was cut before it finished, so nothing was written. Run it again.";
|
|
8058
|
+
var pullShort = (dir) => `The pull's archive is cut short or damaged, so nothing was written to ${dir}. Run it again.`;
|
|
7165
8059
|
var MAX_PULL_BYTES = 256 * 1048576;
|
|
7166
8060
|
var pullApp = (creds, org, app, options = {}) => callApiBytes(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/pull`, options.fetchImpl ? { fetchImpl: options.fetchImpl } : {});
|
|
8061
|
+
function isWholeArchive(archive) {
|
|
8062
|
+
return new Promise((resolve7, reject) => {
|
|
8063
|
+
let sound = true;
|
|
8064
|
+
let ended2 = false;
|
|
8065
|
+
const parser = new TarParser({ onReadEntry: (entry) => void entry.resume() });
|
|
8066
|
+
parser.on("warn", (code) => {
|
|
8067
|
+
if (code === "TAR_BAD_ARCHIVE" || code === "TAR_ENTRY_INVALID") sound = false;
|
|
8068
|
+
});
|
|
8069
|
+
parser.on("eof", () => {
|
|
8070
|
+
ended2 = true;
|
|
8071
|
+
});
|
|
8072
|
+
parser.on("error", (error) => typeof error.code === "string" && error.code.startsWith("Z_") ? resolve7(false) : reject(error));
|
|
8073
|
+
parser.on("end", () => resolve7(sound && ended2));
|
|
8074
|
+
parser.end(archive);
|
|
8075
|
+
});
|
|
8076
|
+
}
|
|
7167
8077
|
async function unpackPull(bytes, dir, options = {}) {
|
|
7168
|
-
await
|
|
7169
|
-
|
|
8078
|
+
const existing = await readdir(dir).catch((error) => {
|
|
8079
|
+
if (error.code === "ENOENT") return [];
|
|
8080
|
+
throw error;
|
|
8081
|
+
});
|
|
7170
8082
|
if (existing.length > 0 && !options.force) {
|
|
7171
8083
|
throw new CliError(`${dir} is not empty.`, "Pull into a new directory, or pass --force to overwrite.");
|
|
7172
8084
|
}
|
|
8085
|
+
const archive = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
8086
|
+
if (!await isWholeArchive(archive)) throw new CliError(pullShort(dir), void 0, { code: "pull_cut" });
|
|
8087
|
+
await mkdir3(dir, { recursive: true });
|
|
7173
8088
|
await new Promise((resolve7, reject) => {
|
|
7174
8089
|
const extractor = tarExtract({ cwd: dir });
|
|
7175
8090
|
extractor.on("error", reject);
|
|
7176
8091
|
extractor.on("finish", resolve7);
|
|
7177
|
-
extractor.end(
|
|
8092
|
+
extractor.end(archive);
|
|
7178
8093
|
});
|
|
7179
8094
|
const written = [];
|
|
7180
8095
|
await collect(dir, dir, written);
|
|
@@ -7551,7 +8466,7 @@ function deletedAppMessage(org, app, done) {
|
|
|
7551
8466
|
return `Deleted ${org}/${app}. Its address has stopped answering${runs}. Its data is kept until ${done.dataKeptUntil.slice(0, 10)}; until then wirl apps restore ${app} --org ${org} brings it back (one that was public comes back only from the Recently deleted list on the dashboard). Its name stays taken.`;
|
|
7552
8467
|
}
|
|
7553
8468
|
function deleteNeedsFullScopeMessage(apiOrigin, org, app) {
|
|
7554
|
-
return `This sign-in can deploy, but not delete an app: deleting one needs a full-scope sign-in. Sign in with
|
|
8469
|
+
return `This sign-in can deploy, but not delete an app: deleting one needs a full-scope sign-in. Sign in with ${WIRL_BIN} login (with WIRL_TOKEN unset), or delete it on its Settings page: ${apiOrigin}/apps/${seg(org)}/${seg(app)}/settings`;
|
|
7555
8470
|
}
|
|
7556
8471
|
var VISIBILITY_LEVELS = ["private", "org", "public"];
|
|
7557
8472
|
function describeVisibility(level, org) {
|
|
@@ -7567,10 +8482,13 @@ function describeVisibility(level, org) {
|
|
|
7567
8482
|
}
|
|
7568
8483
|
}
|
|
7569
8484
|
var readVisibility = (creds, org, app) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}`).then((r) => r.app.visibility ?? "unknown");
|
|
7570
|
-
|
|
7571
|
-
if (!VISIBILITY_LEVELS.includes(
|
|
7572
|
-
throw new CliError(`Visibility is private, org or public, not "${String(
|
|
8485
|
+
function refuseVisibilityLevel(level) {
|
|
8486
|
+
if (!VISIBILITY_LEVELS.includes(level)) {
|
|
8487
|
+
throw new CliError(`Visibility is private, org or public, not "${String(level)}".`);
|
|
7573
8488
|
}
|
|
8489
|
+
}
|
|
8490
|
+
async function changeVisibility(creds, options) {
|
|
8491
|
+
refuseVisibilityLevel(options.level);
|
|
7574
8492
|
if (options.level === "public") {
|
|
7575
8493
|
throw new CliError(
|
|
7576
8494
|
"Public means anyone on the internet with the link can open it, without signing in, so a person decides that with their own click.",
|
|
@@ -7615,11 +8533,59 @@ var transferApp = (creds, org, app, email, options = {}) => callApi(creds, `/api
|
|
|
7615
8533
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
7616
8534
|
});
|
|
7617
8535
|
var unmailedNote = (result) => result.mailed === false && result.message ? ` ${result.message}` : "";
|
|
8536
|
+
function refuseShareRole(value) {
|
|
8537
|
+
const read = readShareRole(value);
|
|
8538
|
+
if (read.ok) return read.role;
|
|
8539
|
+
const shown2 = typeof value === "string" ? terminalSafe(value.slice(0, 40)) : typeof value;
|
|
8540
|
+
throw new CliError(`Role is viewer or editor, not "${shown2}".`);
|
|
8541
|
+
}
|
|
7618
8542
|
var shareApp = (creds, org, app, email, role) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
|
|
7619
8543
|
method: "POST",
|
|
7620
|
-
|
|
8544
|
+
// No role is no `role`: the control plane then keeps the one a person has.
|
|
8545
|
+
body: JSON.stringify(role === void 0 ? { email } : { email, role }),
|
|
7621
8546
|
contentType: "application/json"
|
|
7622
8547
|
});
|
|
8548
|
+
function shareSentence(result, email, asked, voice) {
|
|
8549
|
+
const roleOf = (value) => value === "viewer" || value === "editor" ? value : void 0;
|
|
8550
|
+
const outcome = ["added", "kept", "unchanged", "changed"].includes(String(result.outcome)) ? result.outcome : void 0;
|
|
8551
|
+
const role = roleOf(result.role);
|
|
8552
|
+
const old = outcome === void 0 || role === void 0;
|
|
8553
|
+
const now = role ?? asked ?? "viewer";
|
|
8554
|
+
const was = roleOf(result.previousRole);
|
|
8555
|
+
const article = (name) => name === "editor" ? "an editor" : "a viewer";
|
|
8556
|
+
const mail = unmailedNote(result);
|
|
8557
|
+
const signIn = voice === "cli" ? "the moment they first sign in" : "when they first sign in";
|
|
8558
|
+
if (result.pending) {
|
|
8559
|
+
const wait = `They will have access ${signIn}.`;
|
|
8560
|
+
if (!old && outcome === "kept") return `${email} was already invited as ${now}, and no role was given, so it stays ${now}. ${wait}${mail}`;
|
|
8561
|
+
const lead2 = `Invited ${email} as ${now}. ${wait}`;
|
|
8562
|
+
if (old || outcome === "added") return `${lead2}${mail}`;
|
|
8563
|
+
if (outcome === "unchanged") return `${lead2} The invitation already said that.${mail}`;
|
|
8564
|
+
if (was === void 0) return `${lead2} The invitation's role was changed.${mail}`;
|
|
8565
|
+
return `${lead2} The invitation said ${was} before.${mail}`;
|
|
8566
|
+
}
|
|
8567
|
+
if (!old && outcome === "kept") {
|
|
8568
|
+
const change = voice === "cli" ? "Pass --role viewer or --role editor" : 'Pass role "viewer" or "editor"';
|
|
8569
|
+
return `${email} already had access as ${now}, and no role was given, so it stays ${now}. ${change} to change it.`;
|
|
8570
|
+
}
|
|
8571
|
+
const lead = voice === "cli" ? `Shared with ${email} as ${now}.` : `${email} can now open the app as ${now}.`;
|
|
8572
|
+
if (old || outcome === "added") return lead;
|
|
8573
|
+
if (outcome === "unchanged") return `${lead} They already had that role.`;
|
|
8574
|
+
if (was === void 0) return `${lead} Their role was changed.`;
|
|
8575
|
+
return `${lead} They were ${article(was)} before.`;
|
|
8576
|
+
}
|
|
8577
|
+
var unshareApp = (creds, org, app, email) => callApi(creds, `/api/v1/apps/${seg(org)}/${seg(app)}/share`, {
|
|
8578
|
+
method: "DELETE",
|
|
8579
|
+
body: JSON.stringify({ email }),
|
|
8580
|
+
contentType: "application/json"
|
|
8581
|
+
});
|
|
8582
|
+
function unshareSentence(result, email) {
|
|
8583
|
+
const share = result.shareRevoked ?? result.revoked;
|
|
8584
|
+
const invitation = result.invitationWithdrawn ?? false;
|
|
8585
|
+
const found = share ? invitation ? `Revoked ${email}'s share and withdrew the invitation still waiting for them.` : `Revoked ${email}'s share.` : invitation ? `Withdrew ${email}'s invitation.` : `${email} held no share on the app and had no invitation to it, so there was nothing to take back.`;
|
|
8586
|
+
const wasDeleted = result.appDeleted === true && (share || invitation) ? " The app was deleted when this was asked, so a restore will not bring it back." : "";
|
|
8587
|
+
return `${found}${wasDeleted} That takes back only what was shared by name: the app's level (wirl_visibility) and the rights of its owner and the organisation's owners and admins are unchanged.`;
|
|
8588
|
+
}
|
|
7623
8589
|
var listDeployments = (creds, org, app) => callApi(
|
|
7624
8590
|
creds,
|
|
7625
8591
|
`/api/v1/apps/${seg(org)}/${seg(app)}/deployments`
|
|
@@ -7763,7 +8729,7 @@ var fetchLogs = (creds, org, app, filters) => callApi(
|
|
|
7763
8729
|
);
|
|
7764
8730
|
var LINE_INDENT = " ";
|
|
7765
8731
|
function terminalSafe(text) {
|
|
7766
|
-
return escapeInvisible(text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
8732
|
+
return escapeInvisible(text.replace(/\r\n/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
|
|
7767
8733
|
}
|
|
7768
8734
|
function utcSecond(at2) {
|
|
7769
8735
|
const ms = Date.parse(at2);
|
|
@@ -7799,7 +8765,8 @@ function formatLogPage(page, since) {
|
|
|
7799
8765
|
// queryable. Without this an empty answer right after a request
|
|
7800
8766
|
// reads as "nothing happened".
|
|
7801
8767
|
" A request made in the last minute may not have arrived yet: ask again shortly."
|
|
7802
|
-
)}
|
|
8768
|
+
)}
|
|
8769
|
+
${EDGE_REFUSALS_NOTE}` : page.invocations.map((entry) => formatInvocation(entry)).join("\n\n");
|
|
7803
8770
|
return page.cursor === null ? body : `${body}
|
|
7804
8771
|
|
|
7805
8772
|
More before this: pass cursor=${page.cursor}.`;
|
|
@@ -8345,20 +9312,22 @@ async function previewFetch(creds, org, app, name, path, options) {
|
|
|
8345
9312
|
const headers = callerHeaders(options.headers);
|
|
8346
9313
|
headers.set(VISIT_HEADER, mint.token);
|
|
8347
9314
|
const networkFetch = options.networkFetchImpl ?? fetch;
|
|
8348
|
-
const
|
|
9315
|
+
const clock = options.now ?? (() => performance.now());
|
|
9316
|
+
const sentAt = clock();
|
|
9317
|
+
const elapsed = () => Math.round(clock() - sentAt);
|
|
8349
9318
|
const response = await networkFetch(target.toString(), {
|
|
8350
9319
|
method: options.method ?? (options.body !== void 0 ? "POST" : "GET"),
|
|
8351
9320
|
headers,
|
|
8352
9321
|
redirect: "manual",
|
|
8353
9322
|
...options.body === void 0 ? {} : { body: options.body }
|
|
8354
9323
|
});
|
|
8355
|
-
const headersMs =
|
|
9324
|
+
const headersMs = elapsed();
|
|
8356
9325
|
const contentType = response.headers.get("content-type") ?? "";
|
|
8357
9326
|
const location = response.headers.get("location");
|
|
8358
9327
|
const text = isTextBody(contentType);
|
|
8359
9328
|
const streamWaitMs = Math.min(options.streamWaitMs ?? FETCH_STREAM_WAIT_MS, FETCH_WAIT_MAX_SECONDS * 1e3);
|
|
8360
9329
|
const keep = options.keepBodyBytes === void 0 ? null : Math.min(options.keepBodyBytes, FETCH_FILE_MAX_BYTES);
|
|
8361
|
-
const read = await readBounded(response, streamWaitMs,
|
|
9330
|
+
const read = await readBounded(response, streamWaitMs, elapsed, keep ?? FETCH_READ_CEILING_BYTES);
|
|
8362
9331
|
const bytes = read.bytes;
|
|
8363
9332
|
const truncated = text && (read.cut || bytes.byteLength > FETCH_BODY_CAP_BYTES);
|
|
8364
9333
|
const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
@@ -8446,7 +9415,7 @@ function parseWaitFlag(raw) {
|
|
|
8446
9415
|
}
|
|
8447
9416
|
var FETCH_READ_CEILING_BYTES = 8 * FETCH_BODY_CAP_BYTES;
|
|
8448
9417
|
var FETCH_FILE_MAX_BYTES = 1e8;
|
|
8449
|
-
async function readBounded(response, waitMs,
|
|
9418
|
+
async function readBounded(response, waitMs, elapsed, ceilingBytes) {
|
|
8450
9419
|
if (response.body === null) return { bytes: new Uint8Array(0), chunks: [], stillStreaming: false, cut: false };
|
|
8451
9420
|
const reader = response.body.getReader();
|
|
8452
9421
|
const chunks = [];
|
|
@@ -8465,7 +9434,7 @@ async function readBounded(response, waitMs, sentAt, ceilingBytes) {
|
|
|
8465
9434
|
break;
|
|
8466
9435
|
}
|
|
8467
9436
|
if (next.done) break;
|
|
8468
|
-
chunks.push({ atMs:
|
|
9437
|
+
chunks.push({ atMs: elapsed(), bytes: next.value });
|
|
8469
9438
|
total += next.value.byteLength;
|
|
8470
9439
|
if (total > ceilingBytes) {
|
|
8471
9440
|
cut2 = true;
|
|
@@ -8510,9 +9479,9 @@ function formatFetchResult(result, saved) {
|
|
|
8510
9479
|
0,
|
|
8511
9480
|
`answered after ${seconds(result.headersMs)} s; ${said} line${said === 1 ? "" : "s"} in ${pieces} piece${pieces === 1 ? "" : "s"} over ${seconds(last)} s (each line shown at the time it was complete)`
|
|
8512
9481
|
);
|
|
9482
|
+
const unfinished = lastLineUnfinished(result);
|
|
8513
9483
|
timed.forEach((line, i) => {
|
|
8514
|
-
|
|
8515
|
-
lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished ? " (the line had not ended)" : ""}`);
|
|
9484
|
+
lines.push(line.text === "" ? "" : `+${seconds(line.atMs)}s ${line.text}${unfinished && i === timed.length - 1 ? " (the line had not ended)" : ""}`);
|
|
8516
9485
|
});
|
|
8517
9486
|
} else {
|
|
8518
9487
|
lines.push(result.bodyText);
|
|
@@ -8525,6 +9494,10 @@ function formatFetchResult(result, saved) {
|
|
|
8525
9494
|
}
|
|
8526
9495
|
return lines.join("\n");
|
|
8527
9496
|
}
|
|
9497
|
+
function lastLineUnfinished(result) {
|
|
9498
|
+
return result.timed !== null && !result.timed.lastComplete && (!result.bodyComplete || result.truncated);
|
|
9499
|
+
}
|
|
9500
|
+
var STREAMED_DATA_MAX_TEXT_BYTES = 16 * 1024;
|
|
8528
9501
|
async function readDataFile(file, maxBytes = FETCH_FILE_MAX_BYTES) {
|
|
8529
9502
|
if (file.length === 0) throw new CliError("--data @ needs a file name after the @.", void 0, { code: "bad_flag" });
|
|
8530
9503
|
const tooBig = () => new CliError(
|
|
@@ -8642,7 +9615,7 @@ var ADOPT_SENTENCES2 = {
|
|
|
8642
9615
|
*/
|
|
8643
9616
|
cannotTell: (what) => `wirl did not get the adopt route's answer (${what}), so it cannot tell whether the live database recorded migrations/${ADOPTED_FILE}, and nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file.`,
|
|
8644
9617
|
/** After the call, so the live database records the file; a CLI that reads files otherwise than the control plane says this every time. */
|
|
8645
|
-
notAsRecorded: (install) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but the file wirl sent does not match its own record of it, so nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file. If it says this again, this copy of wirl reads migration files differently from the control plane: update it (npm install -g ${install}).`,
|
|
9618
|
+
notAsRecorded: (install) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but the file wirl sent does not match its own record of it, so nothing was written here. Run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file. If it says this again, this copy of wirl reads migration files differently from the control plane: update it (npm install -g ${install.cli}${hasFallback(install) ? `, or, to match this control plane exactly, npm install -g ${install.tarball.cli}` : ""}).`,
|
|
8646
9619
|
unwritten: (code) => `The live database now records migrations/${ADOPTED_FILE} as adopted, but wirl could not write that file here (${code}). Make migrations/ a folder you can write that holds no .sql file, then run wirl migrations adopt again: while an adopted ${ADOPTED_FILE} is all the live database records, adopting again replaces that record and writes the file.`,
|
|
8647
9620
|
nothingWritten: "Nothing was written to migrations/.",
|
|
8648
9621
|
/**
|
|
@@ -8833,7 +9806,7 @@ async function adoptIntoProject(creds, options) {
|
|
|
8833
9806
|
const read = await readMigrations([{ name: ADOPTED_FILE, text: answer.file.text }], []);
|
|
8834
9807
|
const [file, ...others] = read.ok ? read.files : [];
|
|
8835
9808
|
if (file === void 0 || others.length > 0 || file.sha256 !== answer.file.sha256 || file.statements.length !== answer.file.statements) {
|
|
8836
|
-
throw new CliError(ADOPT_SENTENCES2.notAsRecorded(installFor(creds.apiOrigin)
|
|
9809
|
+
throw new CliError(ADOPT_SENTENCES2.notAsRecorded(installFor(creds.apiOrigin)));
|
|
8837
9810
|
}
|
|
8838
9811
|
const path = await writeAdopted(projectDir, answer.file.text);
|
|
8839
9812
|
const { recordLocalAdoption: recordLocalAdoption2 } = await Promise.resolve().then(() => (init_migrations_local(), migrations_local_exports));
|
|
@@ -8991,6 +9964,137 @@ function formatMigrationsListing(listing, app) {
|
|
|
8991
9964
|
return lines;
|
|
8992
9965
|
}
|
|
8993
9966
|
|
|
9967
|
+
// packages/cli/src/args.ts
|
|
9968
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
9969
|
+
"json",
|
|
9970
|
+
"help",
|
|
9971
|
+
"create",
|
|
9972
|
+
"no-create",
|
|
9973
|
+
"no-skill",
|
|
9974
|
+
"print",
|
|
9975
|
+
"server",
|
|
9976
|
+
"force",
|
|
9977
|
+
"errors",
|
|
9978
|
+
"follow",
|
|
9979
|
+
"fast",
|
|
9980
|
+
"verbose",
|
|
9981
|
+
"yes"
|
|
9982
|
+
]);
|
|
9983
|
+
var EVERYWHERE = {
|
|
9984
|
+
values: ["org", "app"],
|
|
9985
|
+
// `create` was the flag a deploy needed before an app was made by default, and is still read as the boolean it was, to no effect.
|
|
9986
|
+
booleans: ["json", "no-create", "create", "help"]
|
|
9987
|
+
};
|
|
9988
|
+
function commandFlags(listed2, everywhere = true) {
|
|
9989
|
+
return {
|
|
9990
|
+
values: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.values : [], ...listed2.values ?? []]),
|
|
9991
|
+
booleans: /* @__PURE__ */ new Set([...everywhere ? EVERYWHERE.booleans : [], ...listed2.booleans ?? []]),
|
|
9992
|
+
own: new Set(listed2.refusedByCommand ?? [])
|
|
9993
|
+
};
|
|
9994
|
+
}
|
|
9995
|
+
var COMMAND_FLAGS = /* @__PURE__ */ new Map([
|
|
9996
|
+
["login", commandFlags({ values: ["token", "api"] })],
|
|
9997
|
+
["init", commandFlags({ booleans: ["server", "no-skill"] })],
|
|
9998
|
+
["skill", commandFlags({ booleans: ["print"] })],
|
|
9999
|
+
// Only what it lists, as DEV_FLAGS in bin.ts does (OIM-887): `--json` and `--app` were always refused here.
|
|
10000
|
+
["dev", commandFlags({ values: ["user", "name", "role", "org", "port"], booleans: ["verbose"] }, false)],
|
|
10001
|
+
["apps", commandFlags({})],
|
|
10002
|
+
// `--yes` changes nothing on a deploy: no flag answers for the person who approves a destructive file, and the
|
|
10003
|
+
// help says so ("--yes included"). It is read, as the boolean it is, and ignored; an agent that passes it still deploys.
|
|
10004
|
+
["deploy", commandFlags({ booleans: ["yes"] })],
|
|
10005
|
+
["preview", commandFlags({ values: ["name", "delete"], refusedByCommand: ["delete"] })],
|
|
10006
|
+
["previews", commandFlags({})],
|
|
10007
|
+
["fetch", commandFlags({ values: ["preview", "method", "data", "header", "wait", "output"], refusedByCommand: ["preview"] })],
|
|
10008
|
+
["pull", commandFlags({ booleans: ["force"] })],
|
|
10009
|
+
// Only what DELETE_FLAGS in bin.ts lists (OIM-918), and `--preview`, which that file refuses in a sentence about previews.
|
|
10010
|
+
["delete", commandFlags({ values: ["org", "app", "preview"], booleans: ["yes", "json"], refusedByCommand: ["preview"] }, false)],
|
|
10011
|
+
["share", commandFlags({ values: ["role"] })],
|
|
10012
|
+
["unshare", commandFlags({})],
|
|
10013
|
+
["visibility", commandFlags({})],
|
|
10014
|
+
["transfer", commandFlags({})],
|
|
10015
|
+
["move", commandFlags({ values: ["to"] })],
|
|
10016
|
+
["orgs", commandFlags({ values: ["name"] })],
|
|
10017
|
+
["members", commandFlags({ values: ["role"] })],
|
|
10018
|
+
["versions", commandFlags({})],
|
|
10019
|
+
["rollback", commandFlags({})],
|
|
10020
|
+
["migrations", commandFlags({ values: ["preview"] })],
|
|
10021
|
+
["schedules", commandFlags({})],
|
|
10022
|
+
["run", commandFlags({ values: ["preview"] })],
|
|
10023
|
+
["flows", commandFlags({ values: ["preview", "input", "input-file", "key"], booleans: ["fast"] })],
|
|
10024
|
+
["runs", commandFlags({ values: ["flow", "status", "limit", "preview", "data", "from"] })],
|
|
10025
|
+
["logs", commandFlags({ values: ["since", "kind", "limit", "cursor", "run", "ray", "search", "preview"], booleans: ["errors", "follow"] })],
|
|
10026
|
+
["egress", commandFlags({ values: ["since", "run", "limit", "cursor"] })],
|
|
10027
|
+
["connections", commandFlags({ values: ["secret-file", "bearer", "hosts", "kind", "name"] })],
|
|
10028
|
+
["whoami", commandFlags({})]
|
|
10029
|
+
]);
|
|
10030
|
+
function takesValue(command, name) {
|
|
10031
|
+
const table = command === void 0 ? void 0 : COMMAND_FLAGS.get(command);
|
|
10032
|
+
return table === void 0 ? !BOOLEAN_FLAGS.has(name) : table.values.has(name);
|
|
10033
|
+
}
|
|
10034
|
+
function parseArgs(argv) {
|
|
10035
|
+
const positional = [];
|
|
10036
|
+
const flags = /* @__PURE__ */ new Map();
|
|
10037
|
+
const valueless = /* @__PURE__ */ new Set();
|
|
10038
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
10039
|
+
const token = argv[i] ?? "";
|
|
10040
|
+
if (!token.startsWith("--")) {
|
|
10041
|
+
positional.push(token);
|
|
10042
|
+
continue;
|
|
10043
|
+
}
|
|
10044
|
+
const name = token.slice(2);
|
|
10045
|
+
if (!takesValue(positional[0], name)) {
|
|
10046
|
+
flags.set(name, true);
|
|
10047
|
+
continue;
|
|
10048
|
+
}
|
|
10049
|
+
const next = argv[i + 1];
|
|
10050
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
10051
|
+
flags.set(name, next);
|
|
10052
|
+
i += 1;
|
|
10053
|
+
} else {
|
|
10054
|
+
flags.set(name, true);
|
|
10055
|
+
valueless.add(name);
|
|
10056
|
+
}
|
|
10057
|
+
}
|
|
10058
|
+
return { command: positional.shift() ?? "help", positional, flags, valueless };
|
|
10059
|
+
}
|
|
10060
|
+
function textFlag(args2, name) {
|
|
10061
|
+
const value = args2.flags.get(name);
|
|
10062
|
+
if (value === true) throw needsValue(args2, name);
|
|
10063
|
+
return value;
|
|
10064
|
+
}
|
|
10065
|
+
function needsValue(args2, name) {
|
|
10066
|
+
return new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
|
|
10067
|
+
}
|
|
10068
|
+
var NAMED_FLAGS = 6;
|
|
10069
|
+
function refuseUnknownFlags(args2, allowed) {
|
|
10070
|
+
const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name));
|
|
10071
|
+
if (unknown.length === 0) return;
|
|
10072
|
+
const shown2 = unknown.map((name) => {
|
|
10073
|
+
const equals = name.indexOf("=");
|
|
10074
|
+
return equals === -1 ? `--${name}` : `--${name.slice(0, equals)}=\u2026`;
|
|
10075
|
+
});
|
|
10076
|
+
const named = unknown.length > NAMED_FLAGS ? `${shown2.slice(0, NAMED_FLAGS).join(", ")} or ${String(unknown.length - NAMED_FLAGS)} more flags` : `${unknown.length === 1 ? shown2[0] : `${shown2.slice(0, -1).join(", ")} or ${shown2.at(-1)}`} flag`;
|
|
10077
|
+
const withValue = unknown.find((name) => name.includes("="));
|
|
10078
|
+
const where = withValue === void 0 ? "" : withValue.slice(0, withValue.indexOf("="));
|
|
10079
|
+
throw new CliError(
|
|
10080
|
+
`wirl ${args2.command} has no ${named}.`,
|
|
10081
|
+
`wirl ${args2.command} --help lists the flags it takes.${withValue === void 0 ? "" : ` A flag's value is the next word, as in --${where} <value>, not --${where}=<value>.`}`,
|
|
10082
|
+
{ code: "bad_flag" }
|
|
10083
|
+
);
|
|
10084
|
+
}
|
|
10085
|
+
var EMPTY_IS_NONE = /* @__PURE__ */ new Set(["token", "api", "preview", "org", "app"]);
|
|
10086
|
+
function refuseBadFlags(args2) {
|
|
10087
|
+
const table = COMMAND_FLAGS.get(args2.command);
|
|
10088
|
+
if (table === void 0) return;
|
|
10089
|
+
refuseUnknownFlags(args2, /* @__PURE__ */ new Set([...table.values, ...table.booleans]));
|
|
10090
|
+
for (const name of args2.valueless) {
|
|
10091
|
+
if (table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
|
|
10092
|
+
}
|
|
10093
|
+
for (const [name, value] of args2.flags) {
|
|
10094
|
+
if (value === "" && EMPTY_IS_NONE.has(name) && table.values.has(name) && !table.own.has(name)) throw needsValue(args2, name);
|
|
10095
|
+
}
|
|
10096
|
+
}
|
|
10097
|
+
|
|
8994
10098
|
// packages/cli/src/dev.ts
|
|
8995
10099
|
init_src();
|
|
8996
10100
|
import { readdirSync as readdirSync2, realpathSync, statSync, watch as watchFolder } from "node:fs";
|
|
@@ -9298,7 +10402,12 @@ async function startLocalBroker(options) {
|
|
|
9298
10402
|
const keysPath = join6(options.dir, KEYS_FILE);
|
|
9299
10403
|
const log = options.log ?? (() => {
|
|
9300
10404
|
});
|
|
9301
|
-
const transport = createBrokerTransport({
|
|
10405
|
+
const transport = createBrokerTransport({
|
|
10406
|
+
fetchImpl: options.fetchImpl,
|
|
10407
|
+
pinning: options.pinning,
|
|
10408
|
+
heldBodyLimits: options.heldBodyLimits,
|
|
10409
|
+
heldAnswerLimits: options.heldAnswerLimits
|
|
10410
|
+
});
|
|
9302
10411
|
const mocks = options.mocks ?? /* @__PURE__ */ new Map();
|
|
9303
10412
|
const fetchImpl = mocks.size === 0 ? transport.fetchImpl : routeToMocks(
|
|
9304
10413
|
options.manifest,
|
|
@@ -9316,6 +10425,11 @@ async function startLocalBroker(options) {
|
|
|
9316
10425
|
connectionsFor: async () => localConnections(options.manifest, await readLocalKeys(options.dir), keysPath, mocks),
|
|
9317
10426
|
fetchImpl,
|
|
9318
10427
|
streamSlots: transport.streamSlots,
|
|
10428
|
+
// This response, so that an event stream wirl cut waits for its
|
|
10429
|
+
// closing event to leave before the connection is cut (OIM-837).
|
|
10430
|
+
streamSent: sentOn(outgoing),
|
|
10431
|
+
heldBodies: transport.heldBodies,
|
|
10432
|
+
heldAnswers: transport.heldAnswers,
|
|
9319
10433
|
audit: (entry) => {
|
|
9320
10434
|
log(
|
|
9321
10435
|
`wirl dev \xB7 ${entry.connection || "(no connection)"} ${entry.method} ${entry.host || ""} \u2192 ${entry.outcome}${entry.status ? ` ${entry.status}` : ""}`
|
|
@@ -9867,7 +10981,9 @@ var DEV_RELOAD_NOTE = `The server module, and every file it imports, is rebuilt
|
|
|
9867
10981
|
var DEV_KEYS_NOTE = `Keys come from .wirl/keys.json, in the shape this prints for each connection that has none. To try a connection without its key, map it to a server of your own in .wirl/mocks.json, like { "anthropic": "http://127.0.0.1:4010" }. A call to one of that connection's declared hosts then goes to that URL instead, its path added to the URL's own and its query kept. It carries the key from .wirl/keys.json, or, when that has none, the placeholder "wirl-dev-mock" in the shape of the connection's kind. With no key there, an http_header connection sends no key header, since only .wirl/keys.json names the header, and a none connection never sends one. A host the connection does not declare is refused as before. .wirl/mocks.json is read once, at start, and never uploaded.`;
|
|
9868
10982
|
var DEV_FLAGS_NOTE = "A flag not listed below, other than --help, is refused, not ignored.";
|
|
9869
10983
|
var DEV_TWO_PEOPLE_NOTE = "To try two people on one app, run wirl dev a second time in the same folder with another --user: the two share .wirl/local.sqlite, so each sees what the other writes.";
|
|
9870
|
-
var
|
|
10984
|
+
var PREVIEW_LINK_MINUTES = String(VISIT_TOKEN_TTL_SECONDS / 60);
|
|
10985
|
+
var PREVIEW_PASS_MINUTES = String(GRANT_TTL_DEFAULT_SECONDS / 60);
|
|
10986
|
+
var PREVIEW_LINK_WARNING = `Whoever holds this link is signed in as you when they open it, like a password: it works for ${PREVIEW_LINK_MINUTES} minutes, and the sign-in it gives a browser lasts ${PREVIEW_PASS_MINUTES} minutes. Open it in your browser, and do not share it, save it or put it in a file. To check a page or a stream from a script, use wirl fetch.`;
|
|
9871
10987
|
var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
9872
10988
|
|
|
9873
10989
|
wirl login Sign in from this terminal (or --token wirl_...)
|
|
@@ -9884,6 +11000,7 @@ var USAGE = `wirl \u2014 deploy an app somewhere real, behind a sign-in
|
|
|
9884
11000
|
wirl apps restore <name> Bring a deleted app back, inside its ${GRACE_DAYS} days (its owner while a member, or an organisation owner or admin)
|
|
9885
11001
|
wirl delete [app] [--yes] Delete an app: restorable for ${GRACE_DAYS} days, and its name stays taken
|
|
9886
11002
|
wirl share <email> [--role viewer|editor]
|
|
11003
|
+
wirl unshare <email> Take a person's share back (an invitation waiting for them too)
|
|
9887
11004
|
wirl visibility [private|org] See or change who can open the app (public is a person's click on its Access page)
|
|
9888
11005
|
wirl transfer <email> Hand the app to a new owner (you keep editor access)
|
|
9889
11006
|
wirl move --to <org> Move the app to another organisation you belong to
|
|
@@ -9917,6 +11034,7 @@ Options
|
|
|
9917
11034
|
--app <slug> App. Defaults to wirl.json or the directory name.
|
|
9918
11035
|
--no-create Fail instead of creating an app that does not exist yet.
|
|
9919
11036
|
--json Machine-readable output.
|
|
11037
|
+
--version Print the version of wirl you are running, as the first word: wirl --version.
|
|
9920
11038
|
|
|
9921
11039
|
wirl <command> --help for a command's flags and an example.
|
|
9922
11040
|
`;
|
|
@@ -9968,7 +11086,7 @@ ${DEV_FLAGS_NOTE}`,
|
|
|
9968
11086
|
},
|
|
9969
11087
|
deploy: {
|
|
9970
11088
|
usage: ["wirl deploy [dir] [--org <slug>] [--app <slug>] [--no-create] [--json]"],
|
|
9971
|
-
note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks
|
|
11089
|
+
note: `[dir] defaults to wirl.json's "dir", else the current folder. A [dir] with a wirl.json of its own is a project: that wirl.json is the one used (app, org, server, static) and its "dir" is relative to it. A [dir] without one is build output: the wirl.json here is used and that folder goes up. A .wirlignore at its root keeps extra files out of the upload. New files in migrations/ are applied to the app's database before the version goes live; one that fails fails the deploy, and the version already live, if any, keeps serving. A destructive file (one wirl.json's "destructive" list must name) waits for a person before it applies to the live database: the deploy is refused with the address of a page where a person approves it. Run at a terminal, signed in as yourself (not with a deploy token) and without --json, wirl deploy shows the file's destructive lines and asks the person to type its name instead. No flag skips the person, --yes included. A preview never asks. ` + versionNumbersSentence("wirl versions"),
|
|
9972
11090
|
flags: [
|
|
9973
11091
|
ORG_FLAG,
|
|
9974
11092
|
APP_FLAG,
|
|
@@ -10047,13 +11165,19 @@ ${DEV_FLAGS_NOTE}`,
|
|
|
10047
11165
|
share: {
|
|
10048
11166
|
usage: ["wirl share <email> [--role viewer|editor] [--org <slug>] [--app <slug>] [--json]"],
|
|
10049
11167
|
flags: [
|
|
10050
|
-
{ flag: "--role viewer|editor", desc: "Access level.
|
|
11168
|
+
{ flag: "--role viewer|editor", desc: "Access level. A new person is a viewer; someone who already has access keeps their role unless you name one." },
|
|
10051
11169
|
ORG_FLAG,
|
|
10052
11170
|
APP_FLAG,
|
|
10053
11171
|
JSON_FLAG
|
|
10054
11172
|
],
|
|
10055
11173
|
example: "wirl share sam@acme.test --role editor"
|
|
10056
11174
|
},
|
|
11175
|
+
unshare: {
|
|
11176
|
+
usage: ["wirl unshare <email> [--org <slug>] [--app <slug>] [--json]"],
|
|
11177
|
+
note: "With --json, shareRevoked and invitationWithdrawn say what was taken and appDeleted whether the app was deleted; revoked is always true (the request was carried out).",
|
|
11178
|
+
flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
|
|
11179
|
+
example: "wirl unshare sam@acme.test"
|
|
11180
|
+
},
|
|
10057
11181
|
transfer: {
|
|
10058
11182
|
usage: ["wirl transfer <email> [--org <slug>] [--app <slug>] [--json]"],
|
|
10059
11183
|
flags: [ORG_FLAG, APP_FLAG, JSON_FLAG],
|
|
@@ -10192,6 +11316,7 @@ wirl migrations adopt runs once, for an app whose own code made its tables: the
|
|
|
10192
11316
|
"wirl logs [--since 1h] [--errors] [--follow] [--limit n] [--cursor n]",
|
|
10193
11317
|
" [--kind <kind>] [--run <id>] [--ray <id>] [--search <text>] [--preview <name>] [--org <slug>] [--app <slug>] [--json]"
|
|
10194
11318
|
],
|
|
11319
|
+
note: EDGE_REFUSALS_NOTE,
|
|
10195
11320
|
flags: [
|
|
10196
11321
|
{ flag: "--since <window>", desc: "How far back to look, e.g. 1h, 30m. Defaults to 1h." },
|
|
10197
11322
|
{ flag: "--errors", desc: "Only failed requests." },
|
|
@@ -10316,11 +11441,10 @@ function personAt(input, output) {
|
|
|
10316
11441
|
}
|
|
10317
11442
|
|
|
10318
11443
|
// packages/cli/src/bin.ts
|
|
10319
|
-
var out = (line = "") => process.stdout.write(`${
|
|
11444
|
+
var out = (line = "") => process.stdout.write(`${terminalSafe(line)}
|
|
10320
11445
|
`);
|
|
10321
|
-
var err = (line = "") => process.stderr.write(`${
|
|
11446
|
+
var err = (line = "") => process.stderr.write(`${terminalSafe(line)}
|
|
10322
11447
|
`);
|
|
10323
|
-
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "create", "no-create", "no-skill", "print", "server", "force", "errors", "follow", "fast", "verbose", "yes"]);
|
|
10324
11448
|
function readTypedLine(question) {
|
|
10325
11449
|
return new Promise((done) => {
|
|
10326
11450
|
const typed = createInterface2({ input: process.stdin, output: process.stderr, terminal: false });
|
|
@@ -10328,7 +11452,7 @@ function readTypedLine(question) {
|
|
|
10328
11452
|
typed.on("close", () => {
|
|
10329
11453
|
if (!answered) done(null);
|
|
10330
11454
|
});
|
|
10331
|
-
process.stderr.write(
|
|
11455
|
+
process.stderr.write(terminalSafe(question));
|
|
10332
11456
|
typed.once("line", (line) => {
|
|
10333
11457
|
answered = true;
|
|
10334
11458
|
typed.close();
|
|
@@ -10336,39 +11460,6 @@ function readTypedLine(question) {
|
|
|
10336
11460
|
});
|
|
10337
11461
|
});
|
|
10338
11462
|
}
|
|
10339
|
-
function parseArgs(argv) {
|
|
10340
|
-
const positional = [];
|
|
10341
|
-
const flags = /* @__PURE__ */ new Map();
|
|
10342
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
10343
|
-
const token = argv[i] ?? "";
|
|
10344
|
-
if (token.startsWith("--")) {
|
|
10345
|
-
const name = token.slice(2);
|
|
10346
|
-
const next = argv[i + 1];
|
|
10347
|
-
if (!BOOLEAN_FLAGS.has(name) && next !== void 0 && !next.startsWith("--")) {
|
|
10348
|
-
flags.set(name, next);
|
|
10349
|
-
i += 1;
|
|
10350
|
-
} else {
|
|
10351
|
-
flags.set(name, true);
|
|
10352
|
-
}
|
|
10353
|
-
} else {
|
|
10354
|
-
positional.push(token);
|
|
10355
|
-
}
|
|
10356
|
-
}
|
|
10357
|
-
return { command: positional.shift() ?? "help", positional, flags };
|
|
10358
|
-
}
|
|
10359
|
-
function textFlag(args2, name) {
|
|
10360
|
-
const value = args2.flags.get(name);
|
|
10361
|
-
if (value === true) {
|
|
10362
|
-
throw new CliError(`--${name} needs a value.`, `wirl ${args2.command} --help lists what each flag takes.`, { code: "bad_flag" });
|
|
10363
|
-
}
|
|
10364
|
-
return value;
|
|
10365
|
-
}
|
|
10366
|
-
function refuseUnknownFlags(args2, allowed) {
|
|
10367
|
-
const unknown = [...args2.flags.keys()].filter((name) => !allowed.has(name)).map((name) => `--${name}`);
|
|
10368
|
-
if (unknown.length === 0) return;
|
|
10369
|
-
const named = unknown.length === 1 ? unknown[0] : `${unknown.slice(0, -1).join(", ")} or ${unknown.at(-1)}`;
|
|
10370
|
-
throw new CliError(`wirl ${args2.command} has no ${named} flag.`, `wirl ${args2.command} --help lists the flags it takes.`, { code: "bad_flag" });
|
|
10371
|
-
}
|
|
10372
11463
|
var NAMED_WORDS = 6;
|
|
10373
11464
|
function nameWords(words) {
|
|
10374
11465
|
const shown2 = words.map((word) => word === "" ? '""' : word);
|
|
@@ -10395,6 +11486,8 @@ function parsePortFlag(raw) {
|
|
|
10395
11486
|
}
|
|
10396
11487
|
return port;
|
|
10397
11488
|
}
|
|
11489
|
+
var unknownMembersCommand = (sub) => new CliError(`Unknown members command: ${sub}`, "wirl members list | add <email> --role | role <email> --role | remove <email>");
|
|
11490
|
+
var unknownConnectionsCommand = (sub) => new CliError(`Unknown: wirl connections ${sub}`, "list, create, rotate, delete, show, grants, pending, approve, revoke, request");
|
|
10398
11491
|
var RUNS_SUBCOMMANDS = /* @__PURE__ */ new Set(["show", "send", "cancel", "retry"]);
|
|
10399
11492
|
var REFUSES_AS_JSON = /* @__PURE__ */ new Set(["flows", "runs"]);
|
|
10400
11493
|
var refusesAsJson = (args2) => args2.flags.has("json") && REFUSES_AS_JSON.has(args2.command);
|
|
@@ -10411,6 +11504,10 @@ async function resolveTarget(creds, args2, dir) {
|
|
|
10411
11504
|
var args = parseArgs(process.argv.slice(2).map((token) => token === "-h" ? "--help" : token));
|
|
10412
11505
|
async function main() {
|
|
10413
11506
|
const json2 = args.flags.has("json");
|
|
11507
|
+
if (process.argv[2] === "--version" || process.argv[2] === "-v") {
|
|
11508
|
+
out(wirlVersion());
|
|
11509
|
+
return 0;
|
|
11510
|
+
}
|
|
10414
11511
|
if (args.command === "help") {
|
|
10415
11512
|
if (!args.flags.has("help")) {
|
|
10416
11513
|
refuseExtraPositionals("help", args.positional, 1, "one command", { hint: "A command's help lists its subcommands: wirl help <command>." });
|
|
@@ -10424,9 +11521,12 @@ async function main() {
|
|
|
10424
11521
|
return 0;
|
|
10425
11522
|
}
|
|
10426
11523
|
if (args.command === "login") {
|
|
11524
|
+
refuseBadFlags(args);
|
|
10427
11525
|
refuseExtraPositionals("login", args.positional, 0, "no argument", { secret: true, hint: "A token goes after --token: wirl login --token <token>." });
|
|
10428
|
-
const
|
|
10429
|
-
const
|
|
11526
|
+
const given = textFlag(args, "token");
|
|
11527
|
+
const token = (given ?? "").trim();
|
|
11528
|
+
if (given !== void 0 && token === "") throw needsValue(args, "token");
|
|
11529
|
+
const apiOrigin = String(textFlag(args, "api") ?? apiOriginFromEnv()).replace(/\/+$/, "");
|
|
10430
11530
|
if (token) {
|
|
10431
11531
|
out(`Saved to ${await saveCredentials({ apiOrigin, token })}`);
|
|
10432
11532
|
return 0;
|
|
@@ -10447,6 +11547,7 @@ async function main() {
|
|
|
10447
11547
|
return 0;
|
|
10448
11548
|
}
|
|
10449
11549
|
if (args.command === "init") {
|
|
11550
|
+
refuseBadFlags(args);
|
|
10450
11551
|
refuseExtraPositionals("init", args.positional, 1, "one folder", { hint: folderHint("The app's name goes after --app <name>.") });
|
|
10451
11552
|
const dir = resolve6(args.positional[0] ?? ".");
|
|
10452
11553
|
const app = String(args.flags.get("app") ?? basename4(dir));
|
|
@@ -10461,6 +11562,7 @@ async function main() {
|
|
|
10461
11562
|
return 0;
|
|
10462
11563
|
}
|
|
10463
11564
|
if (args.command === "skill") {
|
|
11565
|
+
refuseBadFlags(args);
|
|
10464
11566
|
refuseExtraPositionals("skill", args.positional, 1, "one folder", { hint: folderHint() });
|
|
10465
11567
|
if (args.flags.has("print")) {
|
|
10466
11568
|
out(SKILL_MARKDOWN);
|
|
@@ -10472,6 +11574,7 @@ async function main() {
|
|
|
10472
11574
|
return 0;
|
|
10473
11575
|
}
|
|
10474
11576
|
if (args.command === "dev") {
|
|
11577
|
+
refuseBadFlags(args);
|
|
10475
11578
|
refuseUnknownFlags(args, DEV_FLAGS);
|
|
10476
11579
|
refuseExtraPositionals("dev", args.positional, 1, "one folder", { hint: folderHint() });
|
|
10477
11580
|
const portFlag = textFlag(args, "port");
|
|
@@ -10509,6 +11612,7 @@ async function main() {
|
|
|
10509
11612
|
return 0;
|
|
10510
11613
|
}
|
|
10511
11614
|
const creds = await loadCredentials();
|
|
11615
|
+
refuseBadFlags(args);
|
|
10512
11616
|
switch (args.command) {
|
|
10513
11617
|
case "whoami": {
|
|
10514
11618
|
refuseExtraPositionals("whoami", args.positional, 0, "no argument");
|
|
@@ -10727,6 +11831,8 @@ async function main() {
|
|
|
10727
11831
|
});
|
|
10728
11832
|
} else if (sub === "remove") {
|
|
10729
11833
|
refuseExtraPositionals("members remove", args.positional.slice(1), 1, "one email", { hint: "Run it once for each person." });
|
|
11834
|
+
} else {
|
|
11835
|
+
throw unknownMembersCommand(sub);
|
|
10730
11836
|
}
|
|
10731
11837
|
const org = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "") || (await resolveTarget(creds, args, ".")).org;
|
|
10732
11838
|
if (sub === "list") {
|
|
@@ -10772,7 +11878,7 @@ async function main() {
|
|
|
10772
11878
|
}
|
|
10773
11879
|
return 0;
|
|
10774
11880
|
}
|
|
10775
|
-
throw
|
|
11881
|
+
throw unknownMembersCommand(sub);
|
|
10776
11882
|
}
|
|
10777
11883
|
case "connections": {
|
|
10778
11884
|
const sub = args.positional[0] ?? "list";
|
|
@@ -10795,6 +11901,8 @@ async function main() {
|
|
|
10795
11901
|
refuseExtraPositionals("connections show", words, 1, "one app", { hint: "wirl connections grants lists every app and what it uses (admins)." });
|
|
10796
11902
|
} else if (sub === "approve" || sub === "revoke" || sub === "request") {
|
|
10797
11903
|
refuseExtraPositionals(`connections ${sub}`, words, 2, "an app and a connection", { hint: "Run it once for each connection." });
|
|
11904
|
+
} else {
|
|
11905
|
+
throw unknownConnectionsCommand(sub);
|
|
10798
11906
|
}
|
|
10799
11907
|
const orgSlug = String(args.flags.get("org") ?? (await loadManifest("."))?.org ?? "");
|
|
10800
11908
|
const org = orgSlug || (await resolveTarget(creds, args, ".")).org;
|
|
@@ -10912,7 +12020,7 @@ async function main() {
|
|
|
10912
12020
|
out(`${sub === "approve" ? "Approved" : "Revoked"} ${slug} for ${app}.`);
|
|
10913
12021
|
return 0;
|
|
10914
12022
|
}
|
|
10915
|
-
throw
|
|
12023
|
+
throw unknownConnectionsCommand(sub);
|
|
10916
12024
|
}
|
|
10917
12025
|
case "delete": {
|
|
10918
12026
|
const preview = args.flags.get("preview");
|
|
@@ -10994,6 +12102,7 @@ async function main() {
|
|
|
10994
12102
|
case "visibility": {
|
|
10995
12103
|
refuseExtraPositionals("visibility", args.positional, 1, "one level");
|
|
10996
12104
|
const level = args.positional[0];
|
|
12105
|
+
if (level) refuseVisibilityLevel(level);
|
|
10997
12106
|
const target = await resolveTarget(creds, args, ".");
|
|
10998
12107
|
const now = level ? await changeVisibility(creds, { org: target.org, app: target.app, level }) : await readVisibility(creds, target.org, target.app);
|
|
10999
12108
|
out(json2 ? JSON.stringify({ visibility: now }) : describeVisibility(now, target.org));
|
|
@@ -11005,12 +12114,23 @@ async function main() {
|
|
|
11005
12114
|
});
|
|
11006
12115
|
const email = args.positional[0];
|
|
11007
12116
|
if (!email) throw new CliError("Who should it be shared with?", "wirl share someone@company.com");
|
|
11008
|
-
const role = args.flags.get("role")
|
|
12117
|
+
const role = refuseShareRole(args.flags.get("role"));
|
|
11009
12118
|
const target = await resolveTarget(creds, args, ".");
|
|
11010
12119
|
const result = await shareApp(creds, target.org, target.app, email, role);
|
|
11011
|
-
out(
|
|
11012
|
-
|
|
11013
|
-
|
|
12120
|
+
out(json2 ? JSON.stringify(result) : shareSentence(result, email, role, "cli"));
|
|
12121
|
+
return 0;
|
|
12122
|
+
}
|
|
12123
|
+
case "unshare": {
|
|
12124
|
+
refuseExtraPositionals("unshare", args.positional, 1, "one email", {
|
|
12125
|
+
hint: "Take back one person's whole share at a time. To change a role instead, use wirl share --role."
|
|
12126
|
+
});
|
|
12127
|
+
const email = args.positional[0];
|
|
12128
|
+
if (!email) throw new CliError("Whose access?", "wirl unshare someone@company.com");
|
|
12129
|
+
const target = await resolveTarget(creds, args, ".");
|
|
12130
|
+
if (!isWellFormedSlug(target.app)) throw new CliError(`"${target.app}" is not an app's name, so nothing was changed.`, void 0, { code: "bad_name" });
|
|
12131
|
+
if (!isWellFormedSlug(target.org)) throw new CliError(`"${target.org}" is not an organisation's name, so nothing was changed.`, void 0, { code: "bad_name" });
|
|
12132
|
+
const result = await unshareApp(creds, target.org, target.app, email);
|
|
12133
|
+
out(json2 ? JSON.stringify(result) : unshareSentence(result, email));
|
|
11014
12134
|
return 0;
|
|
11015
12135
|
}
|
|
11016
12136
|
case "versions": {
|