wawesome 0.9.0 → 0.10.0

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.
Files changed (3) hide show
  1. package/README.md +35 -0
  2. package/dist/index.mjs +187 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -501,6 +501,41 @@ That is deliberate — the line that keeps a job off the internet is one line, a
501
501
  quietly honoured its deletion would put the job back on the open internet with nothing said. Every
502
502
  deploy prints the visibility it landed, beside the URL or in place of it.
503
503
 
504
+ ### A domain of your own
505
+
506
+ Add `"domain"` and your App answers at a name you own, beside the address it already has:
507
+
508
+ ```json
509
+ {
510
+ "app": "my-app",
511
+ "function": "api",
512
+ "entry": "src/index.ts",
513
+ "domain": "shop.client.com"
514
+ }
515
+ ```
516
+
517
+ The deploy attaches it and prints two DNS records to add at your registrar. The TXT record proves you
518
+ own the name, which is what issues the certificate. The CNAME points traffic here, and you add that
519
+ one once the certificate is issued, so a site that is already live never spends a minute answering on
520
+ a certificate that is not there yet.
521
+
522
+ Your deploy waits for neither. It attaches the domain, prints the records and finishes, and every
523
+ later deploy states where the domain got to: not verified, ownership verified, certificate issued, or
524
+ serving. A domain that is already serving is a line saying so and nothing else.
525
+
526
+ A custom domain is granted from the Solo plan upwards, one per App. On a plan that grants none, the
527
+ deploy prints the refusal and lands everything else it was doing.
528
+
529
+ The address derived from your workspace slug keeps serving after you attach a domain. Both names
530
+ answer, so webhooks and integrations already pointed at the old one keep working.
531
+
532
+ An apex domain — `client.com` with no `www` — attaches like any other name. Whether a CNAME may sit at
533
+ your zone root is your DNS provider's rule, and where it cannot, attach `www.client.com` and configure
534
+ the redirect at the provider.
535
+
536
+ Deleting the line detaches nothing. Detaching takes a live site dark, which is not something a deploy
537
+ should infer from a deleted line.
538
+
504
539
  ### Reserved headers
505
540
 
506
541
  `x-wawesome-*` belongs to the platform in both directions. It is stripped off the request before your
package/dist/index.mjs CHANGED
@@ -883,7 +883,7 @@ async function buildJs(entryInput, options) {
883
883
  * that has to name this version — `--version`, the dependency a scaffolded
884
884
  * project pins — reads it here, so a release bumps one file.
885
885
  */
886
- const CLI_VERSION = "0.9.0";
886
+ const CLI_VERSION = "0.10.0";
887
887
  //#endregion
888
888
  //#region src/prompt.ts
889
889
  /**
@@ -1481,6 +1481,187 @@ function scheduleLines(schedules) {
1481
1481
  return [" Schedules:", ...[...schedules].sort((a, b) => a.name.localeCompare(b.name)).map((schedule) => ` ${schedule.name.padEnd(nameWidth)} ${schedule.expression.padEnd(expressionWidth)} ${whenItRuns(schedule)}`)];
1482
1482
  }
1483
1483
  //#endregion
1484
+ //#region src/billing.ts
1485
+ function billingPageUrl() {
1486
+ const base = getDashboardUrl().replace(/\/+$/, "");
1487
+ try {
1488
+ return new URL("billing", `${base}/`).toString();
1489
+ } catch {
1490
+ return `${base}/billing`;
1491
+ }
1492
+ }
1493
+ /** Refusals whose remedy is on the billing page and nowhere else. */
1494
+ const PLAN_LIMITS = [
1495
+ "app-slots-exhausted",
1496
+ "storage-exhausted",
1497
+ "paid-plan-required",
1498
+ "payment-required",
1499
+ "schedule-suspended"
1500
+ ];
1501
+ /** The rule itself is the gateway's prose, and is deliberately not restated here. */
1502
+ function planLimitAdvice(reason) {
1503
+ if (!reason || !PLAN_LIMITS.includes(reason)) return "";
1504
+ return `Where to resolve it: ${billingPageUrl()}`;
1505
+ }
1506
+ //#endregion
1507
+ //#region src/domains.ts
1508
+ /**
1509
+ * The domain this project declares, refused here rather than at the gateway so
1510
+ * a value of the wrong shape is a message at the keyboard. What makes a legal
1511
+ * hostname is the platform's rule and is deliberately not restated: a name it
1512
+ * turns down is reported by the deploy that asked, without failing it.
1513
+ */
1514
+ function declaredDomain(value) {
1515
+ if (value === void 0 || value === null) return void 0;
1516
+ if (typeof value === "string" && value.trim()) return value.trim();
1517
+ console.error(`[wawesome] Error: 'domain' in wawesome-function.json is '${String(value)}'.`);
1518
+ console.error("[wawesome] It must be one hostname, e.g. \"shop.client.com\".");
1519
+ process.exit(1);
1520
+ }
1521
+ /**
1522
+ * Attach the declared domain to the App unless it is already attached.
1523
+ *
1524
+ * Never throws, and never blocks on a domain coming up: by the time this runs
1525
+ * the code is deployed, and a certificate nobody has issued yet must not turn a
1526
+ * landed deploy into a failed one. Whatever the platform refuses is carried
1527
+ * back as prose to print rather than raised.
1528
+ */
1529
+ async function applyDeclaredDomain(creds, app, hostname) {
1530
+ let held;
1531
+ try {
1532
+ held = await listDomains(creds, app);
1533
+ } catch (err) {
1534
+ return {
1535
+ kind: "unread",
1536
+ hostname,
1537
+ said: whatWentWrong(err)
1538
+ };
1539
+ }
1540
+ const attached = held.find((domain) => isSameHostname(domain.hostname, hostname));
1541
+ if (attached) return {
1542
+ kind: "unchanged",
1543
+ domain: attached
1544
+ };
1545
+ try {
1546
+ return {
1547
+ kind: "attached",
1548
+ domain: await attachDomain(creds, app, hostname)
1549
+ };
1550
+ } catch (err) {
1551
+ const refusal = err instanceof GatewayError ? err : void 0;
1552
+ return {
1553
+ kind: "refused",
1554
+ hostname,
1555
+ said: whatWentWrong(err),
1556
+ advice: planLimitAdvice(refusal?.reason)
1557
+ };
1558
+ }
1559
+ }
1560
+ /**
1561
+ * The platform's own words where it has any. A credential refusal is read
1562
+ * through the same reading every other command gives one, so a CI runner is
1563
+ * told what its credential lacks rather than to sign in again.
1564
+ */
1565
+ function whatWentWrong(err) {
1566
+ if (err instanceof GatewayError && (err.status === 401 || err.status === 403)) return unauthorizedMessage(err.status, err.body);
1567
+ return errorText(err);
1568
+ }
1569
+ /**
1570
+ * The comparison the platform makes: a name is held lowercase without its
1571
+ * trailing dot, so `shop.client.com.` in the file is the domain already
1572
+ * attached rather than a second one to ask for on every deploy.
1573
+ */
1574
+ function isSameHostname(held, declared) {
1575
+ const same = (name) => name.trim().replace(/\.+$/, "").toLowerCase();
1576
+ return same(held) === same(declared);
1577
+ }
1578
+ async function listDomains(creds, app) {
1579
+ const res = await authorizedFetch(domainsUrl(creds, app), {
1580
+ method: "GET",
1581
+ headers: { Authorization: `Bearer ${creds.tenant_jwt}` }
1582
+ });
1583
+ if (!res.ok) throw await asGatewayError(res, `The App's domains could not be read (HTTP ${res.status}).`);
1584
+ const { domains = [] } = await res.json();
1585
+ return domains;
1586
+ }
1587
+ async function attachDomain(creds, app, hostname) {
1588
+ const res = await authorizedFetch(domainsUrl(creds, app), {
1589
+ method: "POST",
1590
+ headers: {
1591
+ Authorization: `Bearer ${creds.tenant_jwt}`,
1592
+ "Content-Type": "application/json"
1593
+ },
1594
+ body: JSON.stringify({ hostname })
1595
+ });
1596
+ if (!res.ok) throw await asGatewayError(res, `'${hostname}' could not be attached (HTTP ${res.status}).`);
1597
+ const { domain } = await res.json();
1598
+ return domain;
1599
+ }
1600
+ function domainsUrl(creds, app) {
1601
+ return `${creds.gateway_url}/v1/apps/${encodeURIComponent(app)}/domains`;
1602
+ }
1603
+ /**
1604
+ * Where a domain got to, said beside the name itself.
1605
+ *
1606
+ * Each state names the one record that is outstanding, because the two are
1607
+ * added a day apart: the TXT issues the certificate, and the CNAME is pointed
1608
+ * here afterwards so a site that is already live never answers on a
1609
+ * certificate that is not there yet.
1610
+ */
1611
+ function whereItGotTo(state) {
1612
+ if (state.serving) return "serving";
1613
+ if (state.certificate_active) return "certificate issued, so point the CNAME here to cut over";
1614
+ if (state.ownership_verified) return "ownership verified, and the certificate is being issued";
1615
+ return "not verified yet, so add the TXT record below";
1616
+ }
1617
+ function whatItIsFor(record) {
1618
+ return record.purpose === "certificate" ? "Proves you own the name, and issues the certificate" : "Points traffic here, once the certificate is issued";
1619
+ }
1620
+ const VALUE_COLUMN = " ".repeat(14);
1621
+ /**
1622
+ * The block a deploy prints for the domain its configuration declares, in the
1623
+ * columns the visibility and URL lines above it already use.
1624
+ */
1625
+ function domainLines(outcome) {
1626
+ if (outcome.kind === "unread") return [
1627
+ "",
1628
+ ` Domain: \x1b[33m${outcome.hostname} — could not be read\x1b[0m`,
1629
+ ...wrapped(outcome.said).map((line) => `${VALUE_COLUMN}${line}`),
1630
+ `${VALUE_COLUMN}Nothing about the domain changed, and the deploy landed.`
1631
+ ];
1632
+ if (outcome.kind === "refused") return [
1633
+ "",
1634
+ ` Domain: \x1b[33m${outcome.hostname} — not attached\x1b[0m`,
1635
+ ...wrapped(outcome.said).map((line) => `${VALUE_COLUMN}${line}`),
1636
+ ...outcome.advice ? [`${VALUE_COLUMN}${outcome.advice}`] : [],
1637
+ `${VALUE_COLUMN}The deploy landed, and this App's other address still answers.`
1638
+ ];
1639
+ const { hostname, records, state, last_checked_at } = outcome.domain;
1640
+ const lines = ["", ` Domain: \x1b[36m${hostname}\x1b[0m — ${whereItGotTo(state)}`];
1641
+ if (state.serving) {
1642
+ lines.push(`${VALUE_COLUMN}The address above answers too, so nothing pointed at it breaks.`);
1643
+ return lines;
1644
+ }
1645
+ if (last_checked_at) lines.push(`${VALUE_COLUMN}Last checked ${asUtc$1(last_checked_at)}.`);
1646
+ const nameWidth = Math.max(...records.map((record) => record.name.length));
1647
+ lines.push("", " DNS records to add at your registrar:");
1648
+ for (const record of records) lines.push("", ` ${whatItIsFor(record)}:`, ` ${record.type.padEnd(5)} ${record.name.padEnd(nameWidth)} ${record.value}`);
1649
+ lines.push("", `${VALUE_COLUMN}The deploy is finished. This domain comes up on its own`, `${VALUE_COLUMN}once the records resolve.`);
1650
+ return lines;
1651
+ }
1652
+ /** The platform's own prose, kept inside the block it is printed in. */
1653
+ function wrapped(said) {
1654
+ const width = 64;
1655
+ const lines = [];
1656
+ let line = "";
1657
+ for (const word of said.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
1658
+ lines.push(line);
1659
+ line = word;
1660
+ } else line = line ? `${line} ${word}` : word;
1661
+ if (line) lines.push(line);
1662
+ return lines;
1663
+ }
1664
+ //#endregion
1484
1665
  //#region src/usage.ts
1485
1666
  const USAGE_TIMEOUT_MS = 2e3;
1486
1667
  /**
@@ -1640,29 +1821,6 @@ function widest(values) {
1640
1821
  return values.reduce((longest, value) => Math.max(longest, value.length), 0);
1641
1822
  }
1642
1823
  //#endregion
1643
- //#region src/billing.ts
1644
- function billingPageUrl() {
1645
- const base = getDashboardUrl().replace(/\/+$/, "");
1646
- try {
1647
- return new URL("billing", `${base}/`).toString();
1648
- } catch {
1649
- return `${base}/billing`;
1650
- }
1651
- }
1652
- /** Refusals whose remedy is on the billing page and nowhere else. */
1653
- const PLAN_LIMITS = [
1654
- "app-slots-exhausted",
1655
- "storage-exhausted",
1656
- "paid-plan-required",
1657
- "payment-required",
1658
- "schedule-suspended"
1659
- ];
1660
- /** The rule itself is the gateway's prose, and is deliberately not restated here. */
1661
- function planLimitAdvice(reason) {
1662
- if (!reason || !PLAN_LIMITS.includes(reason)) return "";
1663
- return `Where to resolve it: ${billingPageUrl()}`;
1664
- }
1665
- //#endregion
1666
1824
  //#region src/assets.ts
1667
1825
  /**
1668
1826
  * The files under `dir`, hashed.
@@ -1943,6 +2101,7 @@ async function deploy(entryInput, options) {
1943
2101
  visibility: declaredVisibility(config.visibility),
1944
2102
  confirmPublish: Boolean(options.publish)
1945
2103
  };
2104
+ const domainName = declaredDomain(config.domain);
1946
2105
  if (isVerbose) {
1947
2106
  console.log(`[wawesome:verbose] Deploying to app=${app}, function=${funcName}`);
1948
2107
  console.log(`[wawesome:verbose] Gateway: ${creds.gateway_url}`);
@@ -2077,6 +2236,7 @@ async function deploy(entryInput, options) {
2077
2236
  } catch (err) {
2078
2237
  if (isVerbose) console.log(`[wawesome:verbose] Could not resolve the workspace address: ${err instanceof Error ? err.message : err}`);
2079
2238
  }
2239
+ const domain = domainName ? await applyDeclaredDomain(creds, app, domainName) : null;
2080
2240
  let headroom = null;
2081
2241
  try {
2082
2242
  headroom = headroomLines(await fetchTenantUsage(creds));
@@ -2106,6 +2266,7 @@ async function deploy(entryInput, options) {
2106
2266
  console.log(" Set CONTENT_ORIGIN on it, or ALLOW_PATH_INVOCATION_FORM");
2107
2267
  console.log(" for local development.");
2108
2268
  }
2269
+ if (domain) for (const line of domainLines(domain)) console.log(line);
2109
2270
  if (headroom) {
2110
2271
  console.log("");
2111
2272
  for (const line of headroom) console.log(line);
@@ -2116,7 +2277,8 @@ async function deploy(entryInput, options) {
2116
2277
  functionName: funcName,
2117
2278
  version,
2118
2279
  address,
2119
- schedules
2280
+ schedules,
2281
+ domain
2120
2282
  };
2121
2283
  }
2122
2284
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wawesome",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI tool for building and deploying serverless functions on wawesome.io platform",
5
5
  "type": "module",
6
6
  "bin": {