bun-plugin-stx 0.2.409 → 0.2.411

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Compile `{ pattern: path }` into routes, most specific first. Invalid
3
+ * entries are dropped with a warning rather than thrown: a malformed domain
4
+ * route must not stop a site booting.
5
+ */
6
+ export declare function compileDomainRoutes(input: DomainRoutesOption | undefined, warn?: (message: string) => void): DomainRoute[];
7
+ /** The hostname a request was made to, without the port, lowercased. */
8
+ export declare function requestHost(req: Request): string;
9
+ /** The params a host captures for a route, or null when it does not match. */
10
+ export declare function matchDomain(host: string, route: DomainRoute): Record<string, string> | null;
11
+ /**
12
+ * The path a request to `host` at `pathname` should render, or null when no
13
+ * domain route applies (a host that matches nothing, or a path other than
14
+ * the root).
15
+ */
16
+ export declare function resolveDomainRoute(host: string, pathname: string, routes: DomainRoute[]): string | null;
17
+ /**
18
+ * Where a request for another page on a routed host belongs, or null to
19
+ * answer it in place.
20
+ *
21
+ * Only for a route that names `elsewhere`, only for a host that matches it,
22
+ * only for paths other than the root, and only for page requests: `isAsset`
23
+ * is the server's own test for scripts, styles, `/api` and `public/` files,
24
+ * which stay where the page at the root can load them.
25
+ */
26
+ export declare function resolveDomainElsewhere(host: string, pathname: string, search: string, routes: DomainRoute[], isAsset: (pathname: string) => boolean): string | null;
27
+ /** One line for boot output: `{username}.example.com/ → /{username}`. */
28
+ export declare function describeDomainRoutes(routes: DomainRoute[]): string;
29
+ export declare interface DomainRoute {
30
+ pattern: string
31
+ labels: Label[]
32
+ to: string
33
+ elsewhere?: string
34
+ literals: number
35
+ }
36
+ /**
37
+ * Domain routes: a host whose home page is a page of the site.
38
+ *
39
+ * ```ts
40
+ * serve({
41
+ * domains: {
42
+ * '{username}.example.com': '/{username}',
43
+ * 'shop.example.com': '/store',
44
+ * },
45
+ * })
46
+ * ```
47
+ *
48
+ * `chris.example.com/` then renders exactly what `example.com/chris` renders
49
+ * — the same file route, the same `route.params.username` — and the address
50
+ * bar keeps saying `chris.example.com`. It is how a profile, a team or a shop
51
+ * gets a domain of its own on a site that is otherwise one app, the way a
52
+ * GitHub Pages or a Substack subdomain works.
53
+ *
54
+ * Only the host's root is routed. Every other path on that host — scripts,
55
+ * styles, images, `/api`, the other pages a navigation bar links to — is the
56
+ * site's own, answered as it would be on the main domain, so one page can be
57
+ * given a domain without the rest of the site breaking under it.
58
+ *
59
+ * Unless the route says where the rest of the site lives:
60
+ *
61
+ * ```ts
62
+ * domains: {
63
+ * '{username}.example.com': { to: '/{username}', elsewhere: 'https://example.com' },
64
+ * }
65
+ * ```
66
+ *
67
+ * Then a request for any other PAGE on that host is redirected there
68
+ * (`chris.example.com/pricing` → `example.com/pricing`), while its assets,
69
+ * `/api` and `public/` files are still answered in place, because the page
70
+ * at its root needs them. A browser keeps a session per origin: without
71
+ * this, signing in from a profile's own domain signs in to that domain, and
72
+ * the visitor ends up using the whole app under someone else's name.
73
+ *
74
+ * A pattern is a hostname whose labels are either literal or `{name}`, which
75
+ * matches exactly one DNS label (`{username}.example.com` matches
76
+ * `chris.example.com`, not `a.b.example.com` and not the bare apex). Literal
77
+ * patterns win over parameterised ones, then the one with more literal
78
+ * labels, so `docs.example.com` can be carved out of `{username}.example.com`.
79
+ * A parameter's value is placed in the target path URL-encoded.
80
+ *
81
+ * The host is read from `X-Forwarded-Host` before `Host`, because behind a
82
+ * gateway the latter is the upstream's address. Trusting it is safe here: a
83
+ * forged value can only choose which page of this same site renders for the
84
+ * root path, which the requester could have asked for by its own path.
85
+ */
86
+ /** One label of a compiled pattern. */
87
+ declare type Label = { literal: string } | { param: string }
88
+ export type DomainRoutesOption = Record<string, string | { to: string, elsewhere?: string }>;
package/dist/serve.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { serve as bunServe } from 'bun';
2
+ import { compileDomainRoutes, describeDomainRoutes, requestHost, resolveDomainElsewhere, resolveDomainRoute } from './domain-routes';
3
+ import type { DomainRoutesOption } from './domain-routes';
2
4
  import type { MiddlewareContext, MiddlewareHandler, MiddlewareRequest, PageMiddleware, PrepareMiddlewareRequest } from './page-middleware';
5
+ export type { DomainRoute, DomainRoutesOption } from './domain-routes';
3
6
  export type { MiddlewareContext, MiddlewareDefinition, MiddlewareHandler, MiddlewareRequest, PageMiddleware, PrepareMiddlewareRequest } from './page-middleware';
4
7
  /**
5
8
  * Extract real top-level `<script server>` elements without matching tag-like
@@ -358,6 +361,7 @@ export declare interface ServeOptions {
358
361
  | null
359
362
  | undefined
360
363
  | Promise<Response | Record<string, unknown> | null | undefined>
364
+ domains?: DomainRoutesOption
361
365
  onResponse?: (req: Request, response: Response) =>
362
366
  | Response
363
367
  | null
@@ -388,3 +392,4 @@ export declare interface ServeOptions {
388
392
  declare type CssEngine = Pick<typeof import('@stacksjs/ts-css/engine'), 'CSSGenerator' | 'config'>;
389
393
  /** The Bun server `serve()` started - handed to `onRequest` beside each request. */
390
394
  export type ServeServer = ReturnType<typeof bunServe>;
395
+ export { compileDomainRoutes, describeDomainRoutes, matchDomain, requestHost, resolveDomainElsewhere, resolveDomainRoute } from './domain-routes';
package/dist/serve.js CHANGED
@@ -8723,6 +8723,107 @@ import { buildCodeFrame, locateFailureLine } from "@stacksjs/stx/build-message";
8723
8723
  import { clearBundleFailures, getBundleFailures } from "@stacksjs/stx/client-script-bundler";
8724
8724
  import { actionRedirectResponse, compressResponse, runPageAction as sharedRunPageAction } from "@stacksjs/stx";
8725
8725
 
8726
+ // src/domain-routes.ts
8727
+ var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
8728
+ var PARAM = /^\{([A-Za-z_]\w*)\}$/;
8729
+ function compileDomainRoutes(input, warn = () => {}) {
8730
+ const routes = [];
8731
+ for (const [rawPattern, target] of Object.entries(input ?? {})) {
8732
+ const pattern = String(rawPattern).trim().toLowerCase().replace(/\.$/, "");
8733
+ const rawTo = typeof target === "object" && target ? target.to : target;
8734
+ const to = String(rawTo ?? "").trim();
8735
+ let elsewhere;
8736
+ if (typeof target === "object" && target?.elsewhere) {
8737
+ try {
8738
+ const url = new URL(String(target.elsewhere));
8739
+ if (url.protocol === "https:" || url.protocol === "http:")
8740
+ elsewhere = url.origin;
8741
+ } catch {}
8742
+ if (!elsewhere) {
8743
+ warn(`[stx] domain route "${rawPattern}" ignored: elsewhere "${target.elsewhere}" is not an http(s) origin`);
8744
+ continue;
8745
+ }
8746
+ }
8747
+ const parts = pattern.split(".");
8748
+ if (parts.length < 2 || !to.startsWith("/")) {
8749
+ warn(`[stx] domain route "${rawPattern}" -> "${rawTo}" ignored: a route is a hostname with a dot and a path starting with "/"`);
8750
+ continue;
8751
+ }
8752
+ const labels = [];
8753
+ const params = new Set;
8754
+ let valid = true;
8755
+ for (const part of parts) {
8756
+ const param = PARAM.exec(part);
8757
+ if (param) {
8758
+ labels.push({ param: param[1] });
8759
+ params.add(param[1]);
8760
+ } else if (LABEL.test(part)) {
8761
+ labels.push({ literal: part });
8762
+ } else {
8763
+ valid = false;
8764
+ }
8765
+ }
8766
+ const missing = [...to.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).filter((name) => !params.has(name));
8767
+ if (!valid || missing.length) {
8768
+ warn(`[stx] domain route "${rawPattern}" -> "${rawTo}" ignored: ${!valid ? "a label is neither a hostname label nor {name}" : `the path uses {${missing.join("}, {")}} which the hostname does not capture`}`);
8769
+ continue;
8770
+ }
8771
+ routes.push({ pattern, labels, to, ...elsewhere ? { elsewhere } : {}, literals: labels.filter((label) => ("literal" in label)).length });
8772
+ }
8773
+ return routes.sort((a, b) => b.literals - a.literals || b.labels.length - a.labels.length);
8774
+ }
8775
+ function requestHost(req) {
8776
+ const forwarded = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
8777
+ const host = forwarded || req.headers.get("host") || new URL(req.url).host;
8778
+ return host.replace(/:\d+$/, "").replace(/\.$/, "").toLowerCase();
8779
+ }
8780
+ function matchDomain(host, route) {
8781
+ const parts = host.split(".");
8782
+ if (parts.length !== route.labels.length)
8783
+ return null;
8784
+ const params = {};
8785
+ for (let i = 0;i < parts.length; i++) {
8786
+ const label = route.labels[i];
8787
+ const part = parts[i];
8788
+ if ("literal" in label) {
8789
+ if (label.literal !== part)
8790
+ return null;
8791
+ } else {
8792
+ if (!LABEL.test(part))
8793
+ return null;
8794
+ params[label.param] = part;
8795
+ }
8796
+ }
8797
+ return params;
8798
+ }
8799
+ function resolveDomainRoute(host, pathname, routes) {
8800
+ if (!routes.length)
8801
+ return null;
8802
+ if (pathname !== "/" && pathname !== "" && pathname !== "/index")
8803
+ return null;
8804
+ for (const route of routes) {
8805
+ const params = matchDomain(host, route);
8806
+ if (!params)
8807
+ continue;
8808
+ return route.to.replace(/\{(\w+)\}/g, (_, name) => encodeURIComponent(params[name] ?? ""));
8809
+ }
8810
+ return null;
8811
+ }
8812
+ function resolveDomainElsewhere(host, pathname, search, routes, isAsset) {
8813
+ if (!routes.length || pathname === "/" || pathname === "" || pathname === "/index")
8814
+ return null;
8815
+ for (const route of routes) {
8816
+ if (!matchDomain(host, route))
8817
+ continue;
8818
+ if (!route.elsewhere || isAsset(pathname))
8819
+ return null;
8820
+ return `${route.elsewhere}${pathname}${search}`;
8821
+ }
8822
+ return null;
8823
+ }
8824
+ function describeDomainRoutes(routes) {
8825
+ return routes.map((route) => `${route.pattern}/ \u2192 ${route.to}${route.elsewhere ? ` (other pages: ${route.elsewhere})` : ""}`).join(", ");
8826
+ }
8726
8827
  // src/page-middleware.ts
8727
8828
  var DEFAULT_PRIORITY = 10;
8728
8829
  function middlewarePriority(middleware) {
@@ -9301,6 +9402,9 @@ async function serve(options) {
9301
9402
  const fallbackComponentsDir = options.fallbackComponentsDir ?? stxConfig.fallbackComponentsDir;
9302
9403
  const partialsDir = options.partialsDir ?? stxConfig.partialsDir ?? defaultStxConfig.partialsDir;
9303
9404
  const publicDir = options.publicDir ?? stxConfig.publicDir ?? "public";
9405
+ const domainRoutes = compileDomainRoutes(options.domains ?? stxConfig.server?.domains, (message) => console.warn(message));
9406
+ if (domainRoutes.length)
9407
+ console.log(`[stx] domain routes: ${describeDomainRoutes(domainRoutes)}`);
9304
9408
  let markPlaceholdersReady = () => {};
9305
9409
  const placeholdersReady = new Promise((resolve13) => {
9306
9410
  markPlaceholdersReady = resolve13;
@@ -10490,6 +10594,20 @@ ${block}`;
10490
10594
  if (warmup)
10491
10595
  await warmup;
10492
10596
  return compressResponse(req, await (async () => {
10597
+ if (domainRoutes.length) {
10598
+ const original = new URL(req.url);
10599
+ const routed = resolveDomainRoute(requestHost(req), original.pathname, domainRoutes);
10600
+ if (routed) {
10601
+ original.pathname = routed;
10602
+ const headers = new Headers(req.headers);
10603
+ headers.set("x-stx-domain-route", requestHost(req));
10604
+ req = new Request(original, { headers, method: req.method, body: req.body, redirect: req.redirect, duplex: "half" });
10605
+ } else if (req.method === "GET" || req.method === "HEAD") {
10606
+ const location = resolveDomainElsewhere(requestHost(req), original.pathname, original.search, domainRoutes, (pathname) => pathname.startsWith("/api/") || pathname.startsWith("/_stx/") || pathname.startsWith("/.well-known/") || isStaticAssetPath(pathname) || publicFileExists(pathname, publicDir));
10607
+ if (location)
10608
+ return new Response(null, { status: 301, headers: { Location: location } });
10609
+ }
10610
+ }
10493
10611
  const url = new URL(req.url);
10494
10612
  let path = url.pathname;
10495
10613
  const proxyRules = stxConfig.server?.proxy;
@@ -11378,20 +11496,26 @@ export {
11378
11496
  responseTakesCsrfCookie,
11379
11497
  resolveServeOpenUrl,
11380
11498
  resolveImageWarmupBindBudget,
11499
+ resolveDomainRoute,
11500
+ resolveDomainElsewhere,
11501
+ requestHost,
11381
11502
  render404Page,
11382
11503
  publicFileExists,
11383
11504
  projectUsesImageBuiltins,
11384
11505
  pageRootOf,
11385
11506
  pageExcluder,
11386
11507
  orderDynamicRouteFiles,
11508
+ matchDomain,
11387
11509
  isStaticAssetPath,
11388
11510
  isRenderableCacheCandidate,
11389
11511
  isRenderFailure,
11390
11512
  isProductionServe,
11391
11513
  fillRouteParams,
11392
11514
  extractServerScriptsFromTemplate,
11515
+ describeDomainRoutes,
11393
11516
  csrfTokenToMint,
11394
11517
  csrfCookieHeader,
11518
+ compileDomainRoutes,
11395
11519
  bundleBrowserAsset,
11396
11520
  buildDynamicRouteRegexes,
11397
11521
  boundedCache,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bun-plugin-stx",
3
3
  "type": "module",
4
- "version": "0.2.409",
4
+ "version": "0.2.411",
5
5
  "description": "A Bun plugin that allows for using Laravel Blade-like syntax.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -74,7 +74,7 @@
74
74
  "typecheck": "bun tsc --noEmit"
75
75
  },
76
76
  "dependencies": {
77
- "@stacksjs/stx": "0.2.409",
78
- "stx-router": "0.2.409"
77
+ "@stacksjs/stx": "0.2.411",
78
+ "stx-router": "0.2.411"
79
79
  }
80
80
  }