@mohasinac/appkit 4.72.0 → 4.74.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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/seo/sitemap.d.ts +58 -1
- package/dist/_internal/server/features/seo/sitemap.js +89 -18
- package/dist/http/ApiClient.js +20 -1
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +7 -0
- package/dist/http/service-origins.d.ts +68 -0
- package/dist/http/service-origins.js +106 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +1 -0
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.74.0",
|
|
4
|
+
"head": "69eb1a743d0233ce42a9b2836042d71958494e63",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": true,
|
|
7
|
-
"builtAt": "2026-11-
|
|
7
|
+
"builtAt": "2026-11-09T01:51:49.655Z"
|
|
8
8
|
}
|
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next";
|
|
2
|
+
/**
|
|
3
|
+
* Every section this builder can emit. The names are the same keys the
|
|
4
|
+
* per-section count digest logs and `scripts/deploy.mjs` asserts against, so
|
|
5
|
+
* there is one vocabulary for "which part of the sitemap".
|
|
6
|
+
*/
|
|
7
|
+
export declare const SITEMAP_SECTIONS: readonly ["static", "category", "brand", "franchise", "bundle", "group", "blog", "product", "collectorDrop", "preOrder", "clearance", "classified", "digitalCode", "live", "event", "store", "scammer"];
|
|
8
|
+
export type SitemapSection = (typeof SITEMAP_SECTIONS)[number];
|
|
2
9
|
export interface SitemapOptions {
|
|
3
10
|
baseUrl: string;
|
|
11
|
+
/**
|
|
12
|
+
* Emit ONLY these sections. Omit for all of them, which is what the main
|
|
13
|
+
* shop wants.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 Why this exists, and why it is not a nice-to-have
|
|
16
|
+
*
|
|
17
|
+
* The blog and events surfaces now live on their own deployments
|
|
18
|
+
* (`blogs.`/`events.`). Each needs a sitemap carrying **its own** URLs on
|
|
19
|
+
* **its own** host, and the shop needs to stop advertising them — because the
|
|
20
|
+
* moment the shop 301s `/blog/*` to the subdomain, every `/blog` URL left in
|
|
21
|
+
* the shop's sitemap becomes a redirecting entry.
|
|
22
|
+
*
|
|
23
|
+
* That is not a hypothetical. This file's own note at the `disabledRoutes`
|
|
24
|
+
* filter records the measured version: on 2026-10-27 the production sitemap
|
|
25
|
+
* advertised **17 `/blog` URLs and one `/reviews` URL** that already 404'd at
|
|
26
|
+
* the edge — 18 crawl errors on pages we had explicitly asked Google to
|
|
27
|
+
* fetch. And `seo.server.ts` in the consumer records the worse outcome, when
|
|
28
|
+
* half the URL surface followed one host and half another: *every URL in the
|
|
29
|
+
* sitemap 307-redirected and Google dropped the site.*
|
|
30
|
+
*
|
|
31
|
+
* So a host must be able to say "my sitemap is these sections and nothing
|
|
32
|
+
* else", and the two halves must be derived from one list rather than two
|
|
33
|
+
* hand-maintained ones. Hence an allowlist here instead of a second sitemap
|
|
34
|
+
* builder in each extracted app.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 A section NOT selected is not fetched at all — the point is to avoid the
|
|
37
|
+
* Firestore reads too, not merely to filter the result. A subdomain querying
|
|
38
|
+
* the entire product catalogue to throw it away would make every sitemap
|
|
39
|
+
* request pay for sixteen collections it never emits.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 `static` is a section like any other, so an allowlist that omits it
|
|
42
|
+
* emits no static pages. That is deliberate: the shop's static pages
|
|
43
|
+
* (`/about`, `/contact`, the browse routes) belong to the shop, and a
|
|
44
|
+
* subdomain advertising them under its own host would be inventing URLs that
|
|
45
|
+
* do not exist there.
|
|
46
|
+
*/
|
|
47
|
+
sections?: readonly SitemapSection[];
|
|
48
|
+
/**
|
|
49
|
+
* Emit everything EXCEPT these. Mutually exclusive with `sections` in
|
|
50
|
+
* practice; if both are given, `sections` is applied first and this narrows
|
|
51
|
+
* it further.
|
|
52
|
+
*
|
|
53
|
+
* This is the shop's half of the split — `excludeSections: ["blog", "event"]`
|
|
54
|
+
* — and it is spelled as an exclusion rather than a 15-name allowlist so that
|
|
55
|
+
* a NEW section added to this file is carried by the shop automatically. An
|
|
56
|
+
* allowlist there would silently drop the next surface somebody adds, which
|
|
57
|
+
* is the same "measured as empty → looks intentional" failure the digest
|
|
58
|
+
* below exists to catch.
|
|
59
|
+
*/
|
|
60
|
+
excludeSections?: readonly SitemapSection[];
|
|
4
61
|
}
|
|
5
|
-
export declare function buildSitemap({ baseUrl }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
|
|
62
|
+
export declare function buildSitemap({ baseUrl, sections: only, excludeSections, }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
|
|
@@ -67,6 +67,30 @@ function sitemapSectionFailed(label, err) {
|
|
|
67
67
|
serverLogger.error(`sitemap: section "${label}" failed and returned 0 URLs`, { section: label, error: err });
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Every section this builder can emit. The names are the same keys the
|
|
72
|
+
* per-section count digest logs and `scripts/deploy.mjs` asserts against, so
|
|
73
|
+
* there is one vocabulary for "which part of the sitemap".
|
|
74
|
+
*/
|
|
75
|
+
export const SITEMAP_SECTIONS = [
|
|
76
|
+
"static",
|
|
77
|
+
"category",
|
|
78
|
+
"brand",
|
|
79
|
+
"franchise",
|
|
80
|
+
"bundle",
|
|
81
|
+
"group",
|
|
82
|
+
"blog",
|
|
83
|
+
"product",
|
|
84
|
+
"collectorDrop",
|
|
85
|
+
"preOrder",
|
|
86
|
+
"clearance",
|
|
87
|
+
"classified",
|
|
88
|
+
"digitalCode",
|
|
89
|
+
"live",
|
|
90
|
+
"event",
|
|
91
|
+
"store",
|
|
92
|
+
"scammer",
|
|
93
|
+
];
|
|
70
94
|
/**
|
|
71
95
|
* Read every document a query matches, in pages.
|
|
72
96
|
*
|
|
@@ -512,26 +536,39 @@ async function fetchScammerUrls(baseUrl) {
|
|
|
512
536
|
return sitemapSectionFailed("scammer", err);
|
|
513
537
|
}
|
|
514
538
|
}
|
|
515
|
-
export async function buildSitemap({ baseUrl }) {
|
|
539
|
+
export async function buildSitemap({ baseUrl, sections: only, excludeSections, }) {
|
|
540
|
+
/*
|
|
541
|
+
* Which sections this host emits. See `SitemapOptions.sections` for why a
|
|
542
|
+
* host must be able to answer this at all.
|
|
543
|
+
*
|
|
544
|
+
* 🛑 `wants()` gates the FETCH, not the output. An unselected section issues
|
|
545
|
+
* no Firestore query — a subdomain must not pay for sixteen collections it
|
|
546
|
+
* throws away on every sitemap request.
|
|
547
|
+
*/
|
|
548
|
+
const excluded = new Set(excludeSections ?? []);
|
|
549
|
+
const allowed = only ? new Set(only) : null;
|
|
550
|
+
const wants = (s) => !excluded.has(s) && (allowed === null || allowed.has(s));
|
|
551
|
+
/** `[]` for a section this host does not emit, without running its query. */
|
|
552
|
+
const ifWanted = (s, run) => wants(s) ? run() : Promise.resolve([]);
|
|
516
553
|
const [productUrls, categoryUrls, brandUrls, franchiseUrls, bundleUrls, groupedListingUrls, eventUrls, blogUrls, collectorDropUrls, preOrderUrls, clearanceUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls,] = await Promise.all([
|
|
517
|
-
fetchProductUrls(baseUrl),
|
|
518
|
-
fetchCategoryUrls(baseUrl),
|
|
519
|
-
fetchBrandUrls(baseUrl),
|
|
520
|
-
fetchFranchiseUrls(baseUrl),
|
|
521
|
-
fetchBundleUrls(baseUrl),
|
|
522
|
-
fetchGroupedListingUrls(baseUrl),
|
|
523
|
-
fetchEventUrls(baseUrl),
|
|
524
|
-
fetchBlogPostUrls(baseUrl),
|
|
525
|
-
fetchCollectorDropUrls(baseUrl),
|
|
526
|
-
fetchPreOrderUrls(baseUrl),
|
|
527
|
-
fetchClearanceUrls(baseUrl),
|
|
528
|
-
fetchClassifiedUrls(baseUrl),
|
|
529
|
-
fetchDigitalCodeUrls(baseUrl),
|
|
530
|
-
fetchLiveUrls(baseUrl),
|
|
531
|
-
fetchStoreUrls(baseUrl),
|
|
532
|
-
fetchScammerUrls(baseUrl),
|
|
554
|
+
ifWanted("product", () => fetchProductUrls(baseUrl)),
|
|
555
|
+
ifWanted("category", () => fetchCategoryUrls(baseUrl)),
|
|
556
|
+
ifWanted("brand", () => fetchBrandUrls(baseUrl)),
|
|
557
|
+
ifWanted("franchise", () => fetchFranchiseUrls(baseUrl)),
|
|
558
|
+
ifWanted("bundle", () => fetchBundleUrls(baseUrl)),
|
|
559
|
+
ifWanted("group", () => fetchGroupedListingUrls(baseUrl)),
|
|
560
|
+
ifWanted("event", () => fetchEventUrls(baseUrl)),
|
|
561
|
+
ifWanted("blog", () => fetchBlogPostUrls(baseUrl)),
|
|
562
|
+
ifWanted("collectorDrop", () => fetchCollectorDropUrls(baseUrl)),
|
|
563
|
+
ifWanted("preOrder", () => fetchPreOrderUrls(baseUrl)),
|
|
564
|
+
ifWanted("clearance", () => fetchClearanceUrls(baseUrl)),
|
|
565
|
+
ifWanted("classified", () => fetchClassifiedUrls(baseUrl)),
|
|
566
|
+
ifWanted("digitalCode", () => fetchDigitalCodeUrls(baseUrl)),
|
|
567
|
+
ifWanted("live", () => fetchLiveUrls(baseUrl)),
|
|
568
|
+
ifWanted("store", () => fetchStoreUrls(baseUrl)),
|
|
569
|
+
ifWanted("scammer", () => fetchScammerUrls(baseUrl)),
|
|
533
570
|
]);
|
|
534
|
-
const staticUrls = staticPages(baseUrl);
|
|
571
|
+
const staticUrls = wants("static") ? staticPages(baseUrl) : [];
|
|
535
572
|
// Per-section count digest. A section that drops to zero is otherwise
|
|
536
573
|
// indistinguishable from a site that genuinely has none of that thing — which
|
|
537
574
|
// is exactly how `category: 0` survived unnoticed while every other section
|
|
@@ -715,6 +752,28 @@ export async function buildSitemap({ baseUrl }) {
|
|
|
715
752
|
if (page && closed.has(page))
|
|
716
753
|
sections[name] = 0;
|
|
717
754
|
}
|
|
755
|
+
/*
|
|
756
|
+
* 🛑 A section this host does not EMIT is dropped from the digest entirely,
|
|
757
|
+
* not reported as zero.
|
|
758
|
+
*
|
|
759
|
+
* The comment above this block is the reason, and it was written about the
|
|
760
|
+
* `closed` case: *"a warning that fires every build for a reason everyone
|
|
761
|
+
* knows about is how the next real zero gets ignored. 'Closed on purpose'
|
|
762
|
+
* and 'broken' must not look the same in the log."* Section scoping is the
|
|
763
|
+
* same hazard one axis over — without this, the blogs deployment would warn
|
|
764
|
+
* about fifteen empty sections on every single sitemap request, and the shop
|
|
765
|
+
* would warn about `blog` and `event` forever. Two permanent false alarms is
|
|
766
|
+
* how the alarm stops being read.
|
|
767
|
+
*
|
|
768
|
+
* Deleting the key rather than zeroing it also keeps the digest honest for
|
|
769
|
+
* `scripts/deploy.mjs`, which asserts these counts against the deployed
|
|
770
|
+
* sitemap: a section that is absent from the response should be absent here
|
|
771
|
+
* too, not present-and-zero.
|
|
772
|
+
*/
|
|
773
|
+
for (const name of Object.keys(sections)) {
|
|
774
|
+
if (!wants(name))
|
|
775
|
+
delete sections[name];
|
|
776
|
+
}
|
|
718
777
|
const empty = Object.entries(sections)
|
|
719
778
|
.filter(([name, n]) => n === 0 && !closed.has(sectionPage[name]))
|
|
720
779
|
.map(([name]) => name);
|
|
@@ -724,6 +783,18 @@ export async function buildSitemap({ baseUrl }) {
|
|
|
724
783
|
closed: [...closed],
|
|
725
784
|
});
|
|
726
785
|
}
|
|
786
|
+
/*
|
|
787
|
+
* Logged so a host's scope is visible in production, not just in its source.
|
|
788
|
+
* "Why is /blog missing from the sitemap" is otherwise answerable only by
|
|
789
|
+
* reading the caller.
|
|
790
|
+
*/
|
|
791
|
+
if (only || excludeSections?.length) {
|
|
792
|
+
serverLogger.info("sitemap: section scope applied", {
|
|
793
|
+
...(only ? { only: [...only] } : {}),
|
|
794
|
+
...(excludeSections?.length ? { excluded: [...excludeSections] } : {}),
|
|
795
|
+
emitting: Object.keys(sections),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
727
798
|
if (empty.length > 0) {
|
|
728
799
|
serverLogger.warn(`sitemap: ${empty.length} section(s) produced 0 URLs`, { empty, sections, total });
|
|
729
800
|
}
|
package/dist/http/ApiClient.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serviceOriginFor } from "./service-origins.js";
|
|
1
2
|
/**
|
|
2
3
|
* ApiClient — @mohasinac/http
|
|
3
4
|
*
|
|
@@ -34,7 +35,25 @@ export class ApiClient {
|
|
|
34
35
|
this.defaultTimeout = options?.defaultTimeout ?? 120000;
|
|
35
36
|
}
|
|
36
37
|
buildURL(endpoint, params) {
|
|
37
|
-
|
|
38
|
+
/*
|
|
39
|
+
* 🛑 A registered SERVICE ORIGIN wins over this client's own base.
|
|
40
|
+
*
|
|
41
|
+
* `/api/blog/*` and `/api/events/*` are served by their own deployments
|
|
42
|
+
* now. A consumer that wants its data from the owning subdomain registers
|
|
43
|
+
* the prefix once at boot and every call site is routed without edit —
|
|
44
|
+
* which is the point of the seam (owner, 2026-11-09): the consumer keeps
|
|
45
|
+
* its presentation and changes only where the DATA comes from.
|
|
46
|
+
*
|
|
47
|
+
* Order matters. The service map is consulted FIRST because it is the more
|
|
48
|
+
* specific statement: `baseURL` says "where this client points by
|
|
49
|
+
* default", the map says "who owns this path", and a path with a declared
|
|
50
|
+
* owner is owned regardless of which client asked.
|
|
51
|
+
*
|
|
52
|
+
* An unregistered prefix returns `undefined` and nothing below changes —
|
|
53
|
+
* an install that has not adopted the seam behaves exactly as before.
|
|
54
|
+
*/
|
|
55
|
+
const origin = serviceOriginFor(endpoint) ||
|
|
56
|
+
this.baseURL ||
|
|
38
57
|
(typeof window !== "undefined"
|
|
39
58
|
? window.location.origin
|
|
40
59
|
: (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"));
|
package/dist/http/index.d.ts
CHANGED
|
@@ -7,4 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { ApiClient, ApiClientError, apiClient } from "./ApiClient";
|
|
9
9
|
export type { ApiClientOptions, RequestConfig, ApiResponse } from "./ApiClient";
|
|
10
|
+
export { configureServiceOrigins, getServiceOrigins, serviceOriginFor, _resetServiceOrigins, } from "./service-origins";
|
|
11
|
+
export type { ServiceOriginMap } from "./service-origins";
|
|
10
12
|
export { withRetry } from "./retry";
|
package/dist/http/index.js
CHANGED
|
@@ -6,4 +6,11 @@
|
|
|
6
6
|
* Accepts baseURL as a constructor option instead.
|
|
7
7
|
*/
|
|
8
8
|
export { ApiClient, ApiClientError, apiClient } from "./ApiClient.js";
|
|
9
|
+
/*
|
|
10
|
+
* The subdomain seam. `configureServiceOrigins` is how a consumer points
|
|
11
|
+
* `/api/blog/*` or `/api/events/*` at the deployment that OWNS them without
|
|
12
|
+
* editing a single call site. Registering nothing keeps same-origin behaviour
|
|
13
|
+
* exactly, so adoption is opt-in per deployment and per prefix.
|
|
14
|
+
*/
|
|
15
|
+
export { configureServiceOrigins, getServiceOrigins, serviceOriginFor, _resetServiceOrigins, } from "./service-origins.js";
|
|
9
16
|
export { withRetry } from "./retry.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which ORIGIN serves a given API path prefix.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `/api/blog/*` and `/api/events/*` are no longer served by the shop — they
|
|
7
|
+
* moved to their own deployments, and the owner's rule for the split is
|
|
8
|
+
* (2026-11-09):
|
|
9
|
+
*
|
|
10
|
+
* > *"let the events or subdomains give APIs for the CRUD operations so
|
|
11
|
+
* > anywhere can use them, while keeping the carousel slide logic in the
|
|
12
|
+
* > main."*
|
|
13
|
+
*
|
|
14
|
+
* So a consumer keeps its own presentation and changes only where the DATA
|
|
15
|
+
* comes from. That is a one-line change at the data source — **if** the client
|
|
16
|
+
* knows which host owns which prefix. This registry is that knowledge.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 It is CONFIG, not code
|
|
19
|
+
*
|
|
20
|
+
* There is no hostname in this file and there must never be one. A deployment
|
|
21
|
+
* registers its own map at boot from env; an install that registers nothing
|
|
22
|
+
* keeps today's behaviour exactly — every path resolves against the current
|
|
23
|
+
* origin, which is what every consumer did before this existed.
|
|
24
|
+
*
|
|
25
|
+
* That "registers nothing" default is the whole safety story. Adopting the
|
|
26
|
+
* seam is opt-in per deployment and per prefix, so the shop can move `/api/blog`
|
|
27
|
+
* to the subdomain while `/api/events` still resolves locally, verify, and then
|
|
28
|
+
* move the second one. Three small commits instead of one risky switch.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 Longest prefix wins, and the trailing-boundary check is not optional
|
|
31
|
+
*
|
|
32
|
+
* `/api/blog` must not match `/api/blogger`. Matching on a bare
|
|
33
|
+
* `startsWith(prefix)` would route an unrelated endpoint to a host that has
|
|
34
|
+
* never heard of it, and the failure is a 404 from the WRONG server — which
|
|
35
|
+
* reads as "the data is gone" rather than "the routing is wrong". So a match
|
|
36
|
+
* requires the next character to be `/` or `?` or end-of-string.
|
|
37
|
+
*
|
|
38
|
+
* ## What this deliberately does NOT do
|
|
39
|
+
*
|
|
40
|
+
* It does not make the request cross-origin-safe. Pointing a browser at
|
|
41
|
+
* another origin needs CORS on that origin and credentials handling for the
|
|
42
|
+
* session cookie. Both subdomains sit under one registrable domain and the
|
|
43
|
+
* session cookie is widened to it, so a `credentials: "include"` call works —
|
|
44
|
+
* but the CORS headers are the receiving app's job, not this file's.
|
|
45
|
+
*/
|
|
46
|
+
/** `{ "/api/blog": "https://blogs.example.com" }` — prefix → absolute origin. */
|
|
47
|
+
export type ServiceOriginMap = Readonly<Record<string, string>>;
|
|
48
|
+
/**
|
|
49
|
+
* Register the prefix → origin map. Idempotent; last call wins.
|
|
50
|
+
*
|
|
51
|
+
* Entries with a blank value are DROPPED rather than registered, because the
|
|
52
|
+
* value normally comes straight from an env var and an unset var must mean
|
|
53
|
+
* "same origin" — not "resolve against the empty string". A deployment that
|
|
54
|
+
* has not configured the seam yet must behave exactly as it did before.
|
|
55
|
+
*/
|
|
56
|
+
export declare function configureServiceOrigins(map: ServiceOriginMap): void;
|
|
57
|
+
/** Current map, for diagnostics and admin screens. */
|
|
58
|
+
export declare function getServiceOrigins(): ServiceOriginMap;
|
|
59
|
+
/**
|
|
60
|
+
* The origin that serves `path`, or `undefined` for "this origin".
|
|
61
|
+
*
|
|
62
|
+
* `undefined` rather than a default is deliberate: the caller already has a
|
|
63
|
+
* correct same-origin fallback, and returning one from here would hide an
|
|
64
|
+
* unconfigured prefix behind a plausible value.
|
|
65
|
+
*/
|
|
66
|
+
export declare function serviceOriginFor(path: string): string | undefined;
|
|
67
|
+
/** Drop all registrations. Tests only. */
|
|
68
|
+
export declare function _resetServiceOrigins(): void;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which ORIGIN serves a given API path prefix.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `/api/blog/*` and `/api/events/*` are no longer served by the shop — they
|
|
7
|
+
* moved to their own deployments, and the owner's rule for the split is
|
|
8
|
+
* (2026-11-09):
|
|
9
|
+
*
|
|
10
|
+
* > *"let the events or subdomains give APIs for the CRUD operations so
|
|
11
|
+
* > anywhere can use them, while keeping the carousel slide logic in the
|
|
12
|
+
* > main."*
|
|
13
|
+
*
|
|
14
|
+
* So a consumer keeps its own presentation and changes only where the DATA
|
|
15
|
+
* comes from. That is a one-line change at the data source — **if** the client
|
|
16
|
+
* knows which host owns which prefix. This registry is that knowledge.
|
|
17
|
+
*
|
|
18
|
+
* ## 🛑 It is CONFIG, not code
|
|
19
|
+
*
|
|
20
|
+
* There is no hostname in this file and there must never be one. A deployment
|
|
21
|
+
* registers its own map at boot from env; an install that registers nothing
|
|
22
|
+
* keeps today's behaviour exactly — every path resolves against the current
|
|
23
|
+
* origin, which is what every consumer did before this existed.
|
|
24
|
+
*
|
|
25
|
+
* That "registers nothing" default is the whole safety story. Adopting the
|
|
26
|
+
* seam is opt-in per deployment and per prefix, so the shop can move `/api/blog`
|
|
27
|
+
* to the subdomain while `/api/events` still resolves locally, verify, and then
|
|
28
|
+
* move the second one. Three small commits instead of one risky switch.
|
|
29
|
+
*
|
|
30
|
+
* ## 🛑 Longest prefix wins, and the trailing-boundary check is not optional
|
|
31
|
+
*
|
|
32
|
+
* `/api/blog` must not match `/api/blogger`. Matching on a bare
|
|
33
|
+
* `startsWith(prefix)` would route an unrelated endpoint to a host that has
|
|
34
|
+
* never heard of it, and the failure is a 404 from the WRONG server — which
|
|
35
|
+
* reads as "the data is gone" rather than "the routing is wrong". So a match
|
|
36
|
+
* requires the next character to be `/` or `?` or end-of-string.
|
|
37
|
+
*
|
|
38
|
+
* ## What this deliberately does NOT do
|
|
39
|
+
*
|
|
40
|
+
* It does not make the request cross-origin-safe. Pointing a browser at
|
|
41
|
+
* another origin needs CORS on that origin and credentials handling for the
|
|
42
|
+
* session cookie. Both subdomains sit under one registrable domain and the
|
|
43
|
+
* session cookie is widened to it, so a `credentials: "include"` call works —
|
|
44
|
+
* but the CORS headers are the receiving app's job, not this file's.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Module state, like appkit's brand and baseline resolvers. Server and browser
|
|
48
|
+
* are two module graphs, so each registers its own copy at boot — see the note
|
|
49
|
+
* in any consumer's `providers.config.ts` about why both injection points are
|
|
50
|
+
* required and neither is redundant.
|
|
51
|
+
*/
|
|
52
|
+
let originsByPrefix = {};
|
|
53
|
+
/** Longest-first, so `/api/blog/admin` beats `/api/blog` when both are set. */
|
|
54
|
+
let prefixesByLength = [];
|
|
55
|
+
/**
|
|
56
|
+
* Register the prefix → origin map. Idempotent; last call wins.
|
|
57
|
+
*
|
|
58
|
+
* Entries with a blank value are DROPPED rather than registered, because the
|
|
59
|
+
* value normally comes straight from an env var and an unset var must mean
|
|
60
|
+
* "same origin" — not "resolve against the empty string". A deployment that
|
|
61
|
+
* has not configured the seam yet must behave exactly as it did before.
|
|
62
|
+
*/
|
|
63
|
+
export function configureServiceOrigins(map) {
|
|
64
|
+
const cleaned = {};
|
|
65
|
+
for (const [prefix, origin] of Object.entries(map)) {
|
|
66
|
+
const p = prefix.trim();
|
|
67
|
+
const o = origin?.trim();
|
|
68
|
+
if (!p.startsWith("/") || !o)
|
|
69
|
+
continue;
|
|
70
|
+
cleaned[p.replace(/\/+$/, "")] = o.replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
originsByPrefix = cleaned;
|
|
73
|
+
prefixesByLength = Object.keys(cleaned).sort((a, b) => b.length - a.length);
|
|
74
|
+
}
|
|
75
|
+
/** Current map, for diagnostics and admin screens. */
|
|
76
|
+
export function getServiceOrigins() {
|
|
77
|
+
return originsByPrefix;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The origin that serves `path`, or `undefined` for "this origin".
|
|
81
|
+
*
|
|
82
|
+
* `undefined` rather than a default is deliberate: the caller already has a
|
|
83
|
+
* correct same-origin fallback, and returning one from here would hide an
|
|
84
|
+
* unconfigured prefix behind a plausible value.
|
|
85
|
+
*/
|
|
86
|
+
export function serviceOriginFor(path) {
|
|
87
|
+
if (!prefixesByLength.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
// Compare on the pathname only — a query string must not affect routing.
|
|
90
|
+
const pathname = path.split(/[?#]/)[0] ?? path;
|
|
91
|
+
for (const prefix of prefixesByLength) {
|
|
92
|
+
if (!pathname.startsWith(prefix))
|
|
93
|
+
continue;
|
|
94
|
+
// 🛑 Boundary check — see the header. `/api/blog` must not claim
|
|
95
|
+
// `/api/blogger`.
|
|
96
|
+
const next = pathname.charAt(prefix.length);
|
|
97
|
+
if (next === "" || next === "/")
|
|
98
|
+
return originsByPrefix[prefix];
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Drop all registrations. Tests only. */
|
|
103
|
+
export function _resetServiceOrigins() {
|
|
104
|
+
originsByPrefix = {};
|
|
105
|
+
prefixesByLength = [];
|
|
106
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -520,7 +520,8 @@ export { buildSitemap } from "./_internal/server/features/seo/sitemap";
|
|
|
520
520
|
export { buildRobots } from "./_internal/server/features/seo/robots";
|
|
521
521
|
export { buildManifest } from "./_internal/server/features/seo/manifest";
|
|
522
522
|
export { buildDefaultOgImage, DEFAULT_OG_SIZE, resolveOgImageUrl } from "./_internal/server/features/seo/og";
|
|
523
|
-
export type { SitemapOptions } from "./_internal/server/features/seo/sitemap";
|
|
523
|
+
export type { SitemapOptions, SitemapSection } from "./_internal/server/features/seo/sitemap";
|
|
524
|
+
export { SITEMAP_SECTIONS } from "./_internal/server/features/seo/sitemap";
|
|
524
525
|
export type { RobotsOptions } from "./_internal/server/features/seo/robots";
|
|
525
526
|
export type { ManifestOptions } from "./_internal/server/features/seo/manifest";
|
|
526
527
|
export type { DefaultOgOptions } from "./_internal/server/features/seo/og";
|
package/dist/server.js
CHANGED
|
@@ -1475,6 +1475,7 @@ export { buildSitemap } from "./_internal/server/features/seo/sitemap.js";
|
|
|
1475
1475
|
export { buildRobots } from "./_internal/server/features/seo/robots.js";
|
|
1476
1476
|
export { buildManifest } from "./_internal/server/features/seo/manifest.js";
|
|
1477
1477
|
export { buildDefaultOgImage, DEFAULT_OG_SIZE, resolveOgImageUrl } from "./_internal/server/features/seo/og.js";
|
|
1478
|
+
export { SITEMAP_SECTIONS } from "./_internal/server/features/seo/sitemap.js";
|
|
1478
1479
|
// -- generateStaticParams slug enumerators --
|
|
1479
1480
|
// Prerendering a detail route is what puts its <title> in <head> instead of
|
|
1480
1481
|
// streaming it into <body>; see static-params.ts for the measurement.
|