@dwk/host-meta 0.1.0-beta.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/LICENSE +15 -0
- package/README.md +90 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +42 -0
- package/dist/config.js.map +1 -0
- package/dist/document.d.ts +49 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +54 -0
- package/dist/document.js.map +1 -0
- package/dist/handler.d.ts +30 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +109 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/jrd.d.ts +33 -0
- package/dist/jrd.d.ts.map +1 -0
- package/dist/jrd.js +30 -0
- package/dist/jrd.js.map +1 -0
- package/dist/log.d.ts +30 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +28 -0
- package/dist/log.js.map +1 -0
- package/dist/negotiation.d.ts +21 -0
- package/dist/negotiation.d.ts.map +1 -0
- package/dist/negotiation.js +62 -0
- package/dist/negotiation.js.map +1 -0
- package/dist/xrd.d.ts +24 -0
- package/dist/xrd.d.ts.map +1 -0
- package/dist/xrd.js +100 -0
- package/dist/xrd.js.map +1 -0
- package/package.json +49 -0
- package/src/config.ts +96 -0
- package/src/document.ts +82 -0
- package/src/handler.ts +159 -0
- package/src/index.ts +48 -0
- package/src/jrd.ts +51 -0
- package/src/log.ts +32 -0
- package/src/negotiation.ts +65 -0
- package/src/xrd.ts +114 -0
package/src/handler.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-meta fetch handler (RFC 6415): a stateless `GET` endpoint, mountable
|
|
3
|
+
* at both `/.well-known/host-meta` and `/.well-known/host-meta.json`, that
|
|
4
|
+
* serves one request-invariant document in either the XRD (`application/xrd+xml`,
|
|
5
|
+
* the default) or JRD (`application/jrd+json`) representation depending on
|
|
6
|
+
* content negotiation. Discovery data is public, so every response carries
|
|
7
|
+
* permissive CORS (mirroring RFC 7033 §10.2).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type LogFields } from "@dwk/log";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
resolveConfig,
|
|
14
|
+
type ResolvedConfig,
|
|
15
|
+
type HostMetaConfig,
|
|
16
|
+
} from "./config";
|
|
17
|
+
import { HostMetaLogEvent } from "./log";
|
|
18
|
+
import { negotiateFormat, type Format } from "./negotiation";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cloudflare bindings required by the host-meta handler: **none**. The document
|
|
22
|
+
* is config-supplied (composition contract), so this fragment is empty and
|
|
23
|
+
* contributes nothing to the composed Worker's `Env`.
|
|
24
|
+
*/
|
|
25
|
+
export type HostMetaEnv = Record<never, never>;
|
|
26
|
+
|
|
27
|
+
/** A `fetch`-compatible Worker handler. */
|
|
28
|
+
export type HostMetaHandler = (
|
|
29
|
+
request: Request,
|
|
30
|
+
env: HostMetaEnv,
|
|
31
|
+
ctx: ExecutionContext,
|
|
32
|
+
) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
/** Media type for an XRD document (RFC 6415 §2). */
|
|
35
|
+
const XRD_CONTENT_TYPE = "application/xrd+xml; charset=utf-8";
|
|
36
|
+
/** Media type for a JSON Resource Descriptor (RFC 7033 §10.2). */
|
|
37
|
+
const JRD_CONTENT_TYPE = "application/jrd+json; charset=utf-8";
|
|
38
|
+
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* host-meta serves public discovery data to any origin, so every response —
|
|
42
|
+
* success or error — advertises permissive CORS, mirroring the WebFinger policy
|
|
43
|
+
* (RFC 7033 §10.2) it links to.
|
|
44
|
+
*/
|
|
45
|
+
const CORS_HEADERS: Readonly<Record<string, string>> = {
|
|
46
|
+
"access-control-allow-origin": "*",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit a structured event on both the logger and the metrics seam, which share
|
|
51
|
+
* one event vocabulary (see `@dwk/log`): `warn` for rejections, `info` for a
|
|
52
|
+
* served document.
|
|
53
|
+
*/
|
|
54
|
+
function emit(
|
|
55
|
+
config: ResolvedConfig,
|
|
56
|
+
level: "info" | "warn",
|
|
57
|
+
event: string,
|
|
58
|
+
fields?: LogFields,
|
|
59
|
+
): void {
|
|
60
|
+
config.logger[level](event, fields);
|
|
61
|
+
config.metrics.count(event, fields);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function documentResponse(
|
|
65
|
+
body: string,
|
|
66
|
+
contentLength: string,
|
|
67
|
+
format: Format,
|
|
68
|
+
method: string,
|
|
69
|
+
): Response {
|
|
70
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: {
|
|
73
|
+
"content-type": format === "jrd" ? JRD_CONTENT_TYPE : XRD_CONTENT_TYPE,
|
|
74
|
+
// RFC 9110 §9.3.2: a HEAD response carries the same Content-Length the
|
|
75
|
+
// corresponding GET would, so it is set explicitly (the body is dropped
|
|
76
|
+
// for HEAD, which would otherwise omit the header).
|
|
77
|
+
"content-length": contentLength,
|
|
78
|
+
// The representation varies on the negotiated content type; advertise it
|
|
79
|
+
// so shared caches key XRD and JRD responses separately.
|
|
80
|
+
vary: "Accept",
|
|
81
|
+
...CORS_HEADERS,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function errorResponse(
|
|
87
|
+
status: number,
|
|
88
|
+
message: string,
|
|
89
|
+
extraHeaders?: Readonly<Record<string, string>>,
|
|
90
|
+
): Response {
|
|
91
|
+
return new Response(message, {
|
|
92
|
+
status,
|
|
93
|
+
headers: {
|
|
94
|
+
"content-type": TEXT_CONTENT_TYPE,
|
|
95
|
+
...CORS_HEADERS,
|
|
96
|
+
...extraHeaders,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the host-meta handler from configuration.
|
|
103
|
+
*
|
|
104
|
+
* The returned handler is mountable at `/.well-known/host-meta` and
|
|
105
|
+
* `/.well-known/host-meta.json`. It accepts `GET` (and `HEAD`) and returns
|
|
106
|
+
* `200` with the host-meta document: XRD by default, JRD when the client
|
|
107
|
+
* prefers it (`?format=json`, the `host-meta.json` path, or an Accept header
|
|
108
|
+
* favouring `application/jrd+json`). `OPTIONS` returns a CORS preflight; other
|
|
109
|
+
* methods get `405`. Fails loudly at construction if no link source is
|
|
110
|
+
* configured.
|
|
111
|
+
*/
|
|
112
|
+
export function createHostMeta(config: HostMetaConfig): HostMetaHandler {
|
|
113
|
+
const resolved = resolveConfig(config);
|
|
114
|
+
|
|
115
|
+
// Pre-serialized bodies and their byte lengths, computed once: nothing about
|
|
116
|
+
// the document varies per request, so a response is pure header assembly.
|
|
117
|
+
const byteLength = (body: string): string =>
|
|
118
|
+
String(new TextEncoder().encode(body).length);
|
|
119
|
+
const representations: Record<Format, { body: string; length: string }> = {
|
|
120
|
+
xrd: { body: resolved.xrdBody, length: byteLength(resolved.xrdBody) },
|
|
121
|
+
jrd: { body: resolved.jrdBody, length: byteLength(resolved.jrdBody) },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return async (request, _env, _ctx) => {
|
|
125
|
+
const method = request.method;
|
|
126
|
+
|
|
127
|
+
if (method === "OPTIONS") {
|
|
128
|
+
return new Response(null, {
|
|
129
|
+
status: 204,
|
|
130
|
+
headers: {
|
|
131
|
+
...CORS_HEADERS,
|
|
132
|
+
"access-control-allow-methods": "GET, HEAD, OPTIONS",
|
|
133
|
+
"access-control-allow-headers": "*",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
139
|
+
emit(resolved, "warn", HostMetaLogEvent.Rejected, {
|
|
140
|
+
reason: "method_not_allowed",
|
|
141
|
+
});
|
|
142
|
+
return errorResponse(405, "method_not_allowed: use GET", {
|
|
143
|
+
allow: "GET, HEAD, OPTIONS",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const url = new URL(request.url);
|
|
148
|
+
const isJsonPath = url.pathname.endsWith(".json");
|
|
149
|
+
const format = negotiateFormat(
|
|
150
|
+
url,
|
|
151
|
+
request.headers.get("accept"),
|
|
152
|
+
isJsonPath,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const { body, length } = representations[format];
|
|
156
|
+
emit(resolved, "info", HostMetaLogEvent.Served, { format });
|
|
157
|
+
return documentResponse(body, length, format, method);
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/host-meta` — Web Host Metadata (RFC 6415) host-meta discovery endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint package: exports a factory returning a `fetch`-compatible handler,
|
|
5
|
+
* mountable at `/.well-known/host-meta` and `/.well-known/host-meta.json` so it
|
|
6
|
+
* composes with other `@dwk` packages in one Worker. It serves one host-wide
|
|
7
|
+
* resource-discovery document — a set of top-level `Link`s, at minimum an
|
|
8
|
+
* `lrdd` template pointing at the site's WebFinger endpoint — in either the XRD
|
|
9
|
+
* (`application/xrd+xml`, the default) or JRD (`application/jrd+json`)
|
|
10
|
+
* representation, chosen by content negotiation (RFC 6415 §3, RFC 7033 §10.1).
|
|
11
|
+
*
|
|
12
|
+
* It exists because host-meta is **borderline static**: a single-identity site
|
|
13
|
+
* can emit fixed files, but only request logic can negotiate XRD ⇄ JRD from the
|
|
14
|
+
* one URL and template a dynamic `lrdd` link. The package is pure and stateless
|
|
15
|
+
* — the document is config-supplied (a WebFinger URL and/or static links),
|
|
16
|
+
* never read from the global environment — so it ships no Durable Object and
|
|
17
|
+
* needs no bindings, and the serializers unit-test under Node without a Workers
|
|
18
|
+
* runtime. The XRD serializer is the only surface new to this package; the JRD
|
|
19
|
+
* link shaping is reused from `@dwk/webfinger`.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/packages/host-meta.md
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export { createHostMeta } from "./handler";
|
|
26
|
+
export type { HostMetaEnv, HostMetaHandler } from "./handler";
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
resolveConfig,
|
|
30
|
+
type HostMetaConfig,
|
|
31
|
+
type ResolvedConfig,
|
|
32
|
+
} from "./config";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
buildDocument,
|
|
36
|
+
lrddTemplate,
|
|
37
|
+
type HostMetaDocument,
|
|
38
|
+
type Link,
|
|
39
|
+
} from "./document";
|
|
40
|
+
|
|
41
|
+
export { buildHostMetaJrd, serializeJrd, type HostMetaJrd } from "./jrd";
|
|
42
|
+
|
|
43
|
+
export { serializeXrd, escapeXml, XRD_NAMESPACE } from "./xrd";
|
|
44
|
+
|
|
45
|
+
export { negotiateFormat, type Format } from "./negotiation";
|
|
46
|
+
|
|
47
|
+
export { HostMetaLogEvent } from "./log";
|
|
48
|
+
export type { Logger, Metrics } from "@dwk/log";
|
package/src/jrd.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JRD (JSON Resource Descriptor) rendering of a host-meta document
|
|
3
|
+
* (RFC 7033 §10.1, the `host-meta.json` variant of RFC 6415). The host-meta
|
|
4
|
+
* `Link` objects are already the WebFinger JRD link shape — reused verbatim
|
|
5
|
+
* from `@dwk/webfinger` — so rendering is just assembling the top-level members
|
|
6
|
+
* and dropping any the document did not supply, mirroring `@dwk/webfinger`'s
|
|
7
|
+
* `buildJrd`. The XRD serializer (`xrd.ts`) renders the *same* document model,
|
|
8
|
+
* keeping the two representations information-equivalent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { HostMetaDocument, Link } from "./document";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A fully-rendered host-meta JRD. `links` is always present (possibly empty);
|
|
15
|
+
* `subject` and `properties` appear only when the document supplied them, so the
|
|
16
|
+
* JSON stays minimal.
|
|
17
|
+
*/
|
|
18
|
+
export interface HostMetaJrd {
|
|
19
|
+
/** The subject the metadata describes, when configured. */
|
|
20
|
+
readonly subject?: string;
|
|
21
|
+
/** Host-level properties, when configured. */
|
|
22
|
+
readonly properties?: Readonly<Record<string, string | null>>;
|
|
23
|
+
/** The top-level link set. */
|
|
24
|
+
readonly links: readonly Link[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shape a {@link HostMetaDocument} into its {@link HostMetaJrd} object: carry
|
|
29
|
+
* through `subject`/`properties` only when present, and emit the links as-is
|
|
30
|
+
* (they are already JRD-shaped). Exposed so callers can embed the JRD object
|
|
31
|
+
* rather than re-parse the serialized string.
|
|
32
|
+
*/
|
|
33
|
+
export function buildHostMetaJrd(document: HostMetaDocument): HostMetaJrd {
|
|
34
|
+
const jrd: {
|
|
35
|
+
subject?: string;
|
|
36
|
+
properties?: Readonly<Record<string, string | null>>;
|
|
37
|
+
links: readonly Link[];
|
|
38
|
+
} = { links: document.links };
|
|
39
|
+
if (document.subject !== undefined) {
|
|
40
|
+
jrd.subject = document.subject;
|
|
41
|
+
}
|
|
42
|
+
if (document.properties && Object.keys(document.properties).length > 0) {
|
|
43
|
+
jrd.properties = document.properties;
|
|
44
|
+
}
|
|
45
|
+
return jrd;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Serialize a host-meta document to a JRD (`application/jrd+json`) body. */
|
|
49
|
+
export function serializeJrd(document: HostMetaDocument): string {
|
|
50
|
+
return JSON.stringify(buildHostMetaJrd(document));
|
|
51
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/host-meta` — structured observability event taxonomy.
|
|
3
|
+
*
|
|
4
|
+
* host-meta is public, request-invariant discovery data, so the stakes are low
|
|
5
|
+
* — but which representation clients actually negotiate (XRD vs JRD) is useful
|
|
6
|
+
* signal, and a flood of rejected non-`GET` probes is worth a counter. Logging
|
|
7
|
+
* and metrics are opt-in via an injected {@link Logger}/{@link Metrics} (see
|
|
8
|
+
* `@dwk/log`) and **share this one vocabulary**: the same dotted event name is
|
|
9
|
+
* passed to the logger and the metrics sink so a log line and its counter line
|
|
10
|
+
* up. No request carries user data — the document is host-wide — so fields are
|
|
11
|
+
* limited to the chosen `format` and a machine-readable `reason`.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Stable event names emitted by `@dwk/host-meta`. */
|
|
17
|
+
export const HostMetaLogEvent = {
|
|
18
|
+
/**
|
|
19
|
+
* The host-meta document was served. Field: `format` (`"xrd"` or `"jrd"`),
|
|
20
|
+
* the negotiated representation.
|
|
21
|
+
*/
|
|
22
|
+
Served: "host-meta.served",
|
|
23
|
+
/**
|
|
24
|
+
* A request was rejected before the document could be served. Field: `reason`
|
|
25
|
+
* (`method_not_allowed`).
|
|
26
|
+
*/
|
|
27
|
+
Rejected: "host-meta.rejected",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** Union of the event-name string literals in {@link HostMetaLogEvent}. */
|
|
31
|
+
export type HostMetaLogEvent =
|
|
32
|
+
(typeof HostMetaLogEvent)[keyof typeof HostMetaLogEvent];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Representation selection for the one `/.well-known/host-meta` URL (RFC 6415
|
|
3
|
+
* §3). The document is served as XRD (`application/xrd+xml`) **by default** and
|
|
4
|
+
* as JRD (`application/jrd+json`) only when the client specifically prefers it,
|
|
5
|
+
* via — in priority order — an explicit `?format=` override, the
|
|
6
|
+
* `host-meta.json` path (RFC 7033 §10.1, always JRD), or a higher-q Accept
|
|
7
|
+
* preference for JRD over XRD.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The chosen representation. */
|
|
11
|
+
export type Format = "xrd" | "jrd";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The best (highest) q-value among the given exact media types in an `Accept`
|
|
15
|
+
* header, or `0` if none of them appear. Wildcards (`* /*`) are deliberately
|
|
16
|
+
* ignored: they express no preference between XRD and JRD, so a bare `Accept:
|
|
17
|
+
* * /*` (or a missing header) must fall through to the XRD default.
|
|
18
|
+
*/
|
|
19
|
+
function acceptQuality(accept: string, mediaTypes: readonly string[]): number {
|
|
20
|
+
let best = 0;
|
|
21
|
+
for (const part of accept.split(",")) {
|
|
22
|
+
const segments = part.trim().split(";");
|
|
23
|
+
const type = segments[0]?.trim().toLowerCase();
|
|
24
|
+
if (type === undefined || !mediaTypes.includes(type)) continue;
|
|
25
|
+
let q = 1;
|
|
26
|
+
for (const segment of segments.slice(1)) {
|
|
27
|
+
const match = /^\s*q=(.*)$/i.exec(segment);
|
|
28
|
+
if (match && match[1] !== undefined) {
|
|
29
|
+
const parsed = Number.parseFloat(match[1]);
|
|
30
|
+
if (!Number.isNaN(parsed)) q = parsed;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (q > best) best = q;
|
|
34
|
+
}
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide which representation to serve.
|
|
40
|
+
*
|
|
41
|
+
* 1. `?format=json`/`jrd` → JRD; `?format=xml`/`xrd` → XRD (explicit override
|
|
42
|
+
* wins over everything, per RFC 6415 §3's `?format=` mechanism).
|
|
43
|
+
* 2. A request to the `host-meta.json` path → JRD (RFC 7033 §10.1).
|
|
44
|
+
* 3. Otherwise compare Accept q-values: JRD only when it is *strictly*
|
|
45
|
+
* preferred over XRD; XRD is the default for ties, wildcards, and no header.
|
|
46
|
+
*/
|
|
47
|
+
export function negotiateFormat(
|
|
48
|
+
url: URL,
|
|
49
|
+
accept: string | null,
|
|
50
|
+
isJsonPath: boolean,
|
|
51
|
+
): Format {
|
|
52
|
+
const format = url.searchParams.get("format")?.toLowerCase();
|
|
53
|
+
if (format === "json" || format === "jrd") return "jrd";
|
|
54
|
+
if (format === "xml" || format === "xrd") return "xrd";
|
|
55
|
+
|
|
56
|
+
if (isJsonPath) return "jrd";
|
|
57
|
+
|
|
58
|
+
if (accept === null || accept.length === 0) return "xrd";
|
|
59
|
+
const jrdQuality = acceptQuality(accept, [
|
|
60
|
+
"application/jrd+json",
|
|
61
|
+
"application/json",
|
|
62
|
+
]);
|
|
63
|
+
const xrdQuality = acceptQuality(accept, ["application/xrd+xml"]);
|
|
64
|
+
return jrdQuality > xrdQuality ? "jrd" : "xrd";
|
|
65
|
+
}
|
package/src/xrd.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XRD (Extensible Resource Descriptor) rendering of a host-meta document — the
|
|
3
|
+
* default `application/xrd+xml` representation of RFC 6415 §2. This is the only
|
|
4
|
+
* serializer new to `@dwk/host-meta`: the JRD side reuses `@dwk/webfinger`'s
|
|
5
|
+
* link shape. It renders the *same* {@link HostMetaDocument} the JRD serializer
|
|
6
|
+
* does, so the two stay information-equivalent (RFC 6415 §3).
|
|
7
|
+
*
|
|
8
|
+
* Output maps the model onto the XRD 1.0 schema: `Subject`, `Property`
|
|
9
|
+
* (`xsi:nil="true"` for an absent/`null` value), and `Link` elements with
|
|
10
|
+
* `rel`/`type`/`href`/`template` attributes and nested `Title`/`Property`
|
|
11
|
+
* children. All text and attribute values are XML-escaped.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HostMetaDocument, Link } from "./document";
|
|
15
|
+
|
|
16
|
+
/** The XRD 1.0 default namespace (OASIS). */
|
|
17
|
+
export const XRD_NAMESPACE = "http://docs.oasis-open.org/ns/xri/xrd-1.0";
|
|
18
|
+
/** The XML Schema instance namespace, used for `xsi:nil` on absent properties. */
|
|
19
|
+
const XSI_NAMESPACE = "http://www.w3.org/2001/XMLSchema-instance";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Escape a string for inclusion in XML text or a double-quoted attribute value:
|
|
23
|
+
* the five predefined entities. Applied to every dynamic value so operator- or
|
|
24
|
+
* resource-supplied content can never break out of the document structure.
|
|
25
|
+
*/
|
|
26
|
+
export function escapeXml(value: string): string {
|
|
27
|
+
return value
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether any property (top-level or per-link) is `null`, requiring the `xsi` namespace. */
|
|
36
|
+
function needsXsi(document: HostMetaDocument): boolean {
|
|
37
|
+
// Mirror linkXml's null-safety: a missing properties bag (`undefined` or a
|
|
38
|
+
// runtime `null` from untyped callers) carries no nil values to declare.
|
|
39
|
+
const hasNil = (
|
|
40
|
+
props?: Readonly<Record<string, string | null>> | null,
|
|
41
|
+
): boolean =>
|
|
42
|
+
props !== undefined &&
|
|
43
|
+
props !== null &&
|
|
44
|
+
Object.values(props).some((v) => v === null);
|
|
45
|
+
if (hasNil(document.properties)) return true;
|
|
46
|
+
return document.links.some((link) => hasNil(link.properties));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render a single `<Property>` element at the given indent. */
|
|
50
|
+
function propertyXml(
|
|
51
|
+
type: string,
|
|
52
|
+
value: string | null,
|
|
53
|
+
indent: string,
|
|
54
|
+
): string {
|
|
55
|
+
if (value === null) {
|
|
56
|
+
return `${indent}<Property type="${escapeXml(type)}" xsi:nil="true"/>`;
|
|
57
|
+
}
|
|
58
|
+
return `${indent}<Property type="${escapeXml(type)}">${escapeXml(value)}</Property>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Render a single `<Link>` element, expanding to a child block only when it has titles/properties. */
|
|
62
|
+
function linkXml(link: Link): string[] {
|
|
63
|
+
const attrs = [`rel="${escapeXml(link.rel)}"`];
|
|
64
|
+
if (link.type !== undefined) attrs.push(`type="${escapeXml(link.type)}"`);
|
|
65
|
+
if (link.href !== undefined) attrs.push(`href="${escapeXml(link.href)}"`);
|
|
66
|
+
if (link.template !== undefined) {
|
|
67
|
+
attrs.push(`template="${escapeXml(link.template)}"`);
|
|
68
|
+
}
|
|
69
|
+
const open = ` <Link ${attrs.join(" ")}`;
|
|
70
|
+
|
|
71
|
+
const titles = link.titles ? Object.entries(link.titles) : [];
|
|
72
|
+
const properties = link.properties ? Object.entries(link.properties) : [];
|
|
73
|
+
if (titles.length === 0 && properties.length === 0) {
|
|
74
|
+
return [`${open}/>`];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines = [`${open}>`];
|
|
78
|
+
for (const [lang, title] of titles) {
|
|
79
|
+
// RFC 7033 §4.4.4.3 uses "und" for an undetermined language; XRD omits the
|
|
80
|
+
// attribute in that case rather than emitting `xml:lang="und"`.
|
|
81
|
+
const langAttr = lang === "und" ? "" : ` xml:lang="${escapeXml(lang)}"`;
|
|
82
|
+
lines.push(` <Title${langAttr}>${escapeXml(title)}</Title>`);
|
|
83
|
+
}
|
|
84
|
+
for (const [type, value] of properties) {
|
|
85
|
+
lines.push(propertyXml(type, value, " "));
|
|
86
|
+
}
|
|
87
|
+
lines.push(" </Link>");
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Serialize a host-meta document to an XRD (`application/xrd+xml`) body. */
|
|
92
|
+
export function serializeXrd(document: HostMetaDocument): string {
|
|
93
|
+
const rootAttrs = needsXsi(document)
|
|
94
|
+
? `xmlns="${XRD_NAMESPACE}" xmlns:xsi="${XSI_NAMESPACE}"`
|
|
95
|
+
: `xmlns="${XRD_NAMESPACE}"`;
|
|
96
|
+
|
|
97
|
+
const lines = [
|
|
98
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
99
|
+
`<XRD ${rootAttrs}>`,
|
|
100
|
+
];
|
|
101
|
+
if (document.subject !== undefined) {
|
|
102
|
+
lines.push(` <Subject>${escapeXml(document.subject)}</Subject>`);
|
|
103
|
+
}
|
|
104
|
+
if (document.properties) {
|
|
105
|
+
for (const [type, value] of Object.entries(document.properties)) {
|
|
106
|
+
lines.push(propertyXml(type, value, " "));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const link of document.links) {
|
|
110
|
+
lines.push(...linkXml(link));
|
|
111
|
+
}
|
|
112
|
+
lines.push("</XRD>");
|
|
113
|
+
return lines.join("\n") + "\n";
|
|
114
|
+
}
|