@botiverse/porthole 0.1.8 → 0.2.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/conformance/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/served.js +13 -3
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/served.ts +14 -3
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@botiverse/porthole/node`: the host kit on Node's own HTTP server (P066). Every request and
|
|
3
|
+
* WebSocket whose Host is under the content domain is Porthole's; the app's reserved labels
|
|
4
|
+
* (previews) and its own hosts go to the app's listeners.
|
|
5
|
+
*
|
|
6
|
+
* const content = createContentHost({ …, health: { probe: nodeProbe() } });
|
|
7
|
+
* const porthole = nodeHost(content, { request: appListener, upgrade: appUpgrade });
|
|
8
|
+
* const server = http.createServer(porthole.request);
|
|
9
|
+
* server.on("upgrade", porthole.upgrade);
|
|
10
|
+
* server.listen(8080, () => content.start());
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import { request as httpRequest } from "node:http";
|
|
14
|
+
import { request as httpsRequest } from "node:https";
|
|
15
|
+
import type { LookupFunction } from "node:net";
|
|
16
|
+
import type { Duplex } from "node:stream";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import type { ContentHost, HealthProbe } from "../server/host.ts";
|
|
19
|
+
import type { Access } from "../server/types.ts";
|
|
20
|
+
|
|
21
|
+
type RequestListener = (request: IncomingMessage, response: ServerResponse) => void;
|
|
22
|
+
type UpgradeListener = (request: IncomingMessage, socket: Duplex, head: Buffer) => void;
|
|
23
|
+
|
|
24
|
+
export interface NodeHostOptions {
|
|
25
|
+
/** The app's own request listener (Express, Hono's `getRequestListener`, a router). */
|
|
26
|
+
readonly request: RequestListener;
|
|
27
|
+
/** The app's own upgrade listener (its WebSockets, and its reserved labels' sockets). */
|
|
28
|
+
readonly upgrade?: UpgradeListener;
|
|
29
|
+
/** A url-mode app's WebSocket, once its cookie checks out (`ContentHost.access`). */
|
|
30
|
+
readonly appSocket?: (
|
|
31
|
+
access: Access,
|
|
32
|
+
request: IncomingMessage,
|
|
33
|
+
socket: Duplex,
|
|
34
|
+
head: Buffer,
|
|
35
|
+
) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NodeHost {
|
|
39
|
+
readonly request: RequestListener;
|
|
40
|
+
readonly upgrade: UpgradeListener;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The Node request as a fetch `Request`: an absolute-form target kept as it came (P056). */
|
|
44
|
+
export function toRequest(request: IncomingMessage, scheme: "https" | "http"): Request {
|
|
45
|
+
const headers = new Headers();
|
|
46
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
47
|
+
for (const each of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
|
|
48
|
+
headers.append(name, each);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const target = request.url ?? "/";
|
|
52
|
+
const url = target.startsWith("/")
|
|
53
|
+
? `${scheme}://${request.headers.host ?? "localhost"}${target}`
|
|
54
|
+
: target;
|
|
55
|
+
const method = request.method ?? "GET";
|
|
56
|
+
const body =
|
|
57
|
+
method === "GET" || method === "HEAD" ? null : (Readable.toWeb(request) as ReadableStream);
|
|
58
|
+
return new Request(url, { method, headers, body, duplex: "half" } as RequestInit);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Writes a fetch `Response` to a Node response, cookies one header each. */
|
|
62
|
+
export async function sendResponse(response: ServerResponse, answer: Response): Promise<void> {
|
|
63
|
+
const headers: Record<string, string | string[]> = {};
|
|
64
|
+
for (const [name, value] of answer.headers) {
|
|
65
|
+
if (name !== "set-cookie") {
|
|
66
|
+
headers[name] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const cookies = answer.headers.getSetCookie();
|
|
70
|
+
if (cookies.length > 0) {
|
|
71
|
+
headers["set-cookie"] = cookies;
|
|
72
|
+
}
|
|
73
|
+
response.writeHead(answer.status, headers);
|
|
74
|
+
if (answer.body === null) {
|
|
75
|
+
response.end();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for await (const chunk of answer.body as unknown as AsyncIterable<Uint8Array>) {
|
|
79
|
+
response.write(chunk);
|
|
80
|
+
}
|
|
81
|
+
response.end();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a Node request's Host (or absolute-form target) is under the content domain. */
|
|
85
|
+
function forContent<V>(content: ContentHost<V>, request: IncomingMessage): boolean {
|
|
86
|
+
const domain = content.domain;
|
|
87
|
+
if (domain === null) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const host = request.headers.host ?? "";
|
|
91
|
+
let target = "";
|
|
92
|
+
try {
|
|
93
|
+
target = (request.url ?? "/").startsWith("/") ? "" : new URL(request.url ?? "").hostname;
|
|
94
|
+
} catch {
|
|
95
|
+
target = "";
|
|
96
|
+
}
|
|
97
|
+
return domain.isContentHost(host) || (target !== "" && domain.isContentHost(target));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Refuses a WebSocket on the content domain with a plain 404, and closes it. */
|
|
101
|
+
function refuseSocket(socket: Duplex): void {
|
|
102
|
+
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function nodeHost<V>(content: ContentHost<V>, app: NodeHostOptions): NodeHost {
|
|
106
|
+
const scheme = () => content.domain?.scheme ?? "http";
|
|
107
|
+
return {
|
|
108
|
+
request(request, response) {
|
|
109
|
+
if (!forContent(content, request)) {
|
|
110
|
+
app.request(request, response);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
content.fetch(toRequest(request, scheme())).then(
|
|
114
|
+
(answer) => {
|
|
115
|
+
if (answer === null) {
|
|
116
|
+
// A label the app routes itself.
|
|
117
|
+
app.request(request, response);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
return sendResponse(response, answer);
|
|
121
|
+
},
|
|
122
|
+
() => {
|
|
123
|
+
if (!response.headersSent) {
|
|
124
|
+
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
125
|
+
}
|
|
126
|
+
response.end("Content couldn't be served.");
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
upgrade(request, socket, head) {
|
|
131
|
+
if (!forContent(content, request)) {
|
|
132
|
+
if (app.upgrade === undefined) {
|
|
133
|
+
refuseSocket(socket);
|
|
134
|
+
} else {
|
|
135
|
+
app.upgrade(request, socket, head);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const asked = toRequest(request, scheme());
|
|
140
|
+
// A reserved label's socket is the app's.
|
|
141
|
+
if (!content.owns(asked)) {
|
|
142
|
+
if (app.upgrade === undefined) {
|
|
143
|
+
refuseSocket(socket);
|
|
144
|
+
} else {
|
|
145
|
+
app.upgrade(request, socket, head);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const appSocket = app.appSocket;
|
|
150
|
+
if (appSocket === undefined) {
|
|
151
|
+
refuseSocket(socket);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
content.access(asked).then(
|
|
155
|
+
(access) => {
|
|
156
|
+
if (access === null) {
|
|
157
|
+
refuseSocket(socket);
|
|
158
|
+
} else {
|
|
159
|
+
appSocket(access, request, socket, head);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
() => refuseSocket(socket),
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Resolves every `*.localhost` name to loopback, as browsers do (Node's resolver may not). */
|
|
169
|
+
const loopbackLookup: LookupFunction = (hostname, options, callback) => {
|
|
170
|
+
const all = typeof options === "object" && options !== null && options.all === true;
|
|
171
|
+
if (all) {
|
|
172
|
+
(
|
|
173
|
+
callback as unknown as (error: null, addresses: { address: string; family: number }[]) => void
|
|
174
|
+
)(null, [{ address: "127.0.0.1", family: 4 }]);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
callback(null, "127.0.0.1", 4);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The health gate's probe on Node: a GET over the real network (DNS, the edge, TLS with Node's
|
|
182
|
+
* own certificate checks), `*.localhost` resolved to loopback, redirects not followed, at most 4 KB
|
|
183
|
+
* of the answer read.
|
|
184
|
+
*/
|
|
185
|
+
export function nodeProbe(): HealthProbe {
|
|
186
|
+
return (url, timeoutMs) =>
|
|
187
|
+
new Promise((resolve, reject) => {
|
|
188
|
+
const target = new URL(url);
|
|
189
|
+
const local = target.hostname === "localhost" || target.hostname.endsWith(".localhost");
|
|
190
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
191
|
+
const asked = send(
|
|
192
|
+
target,
|
|
193
|
+
{
|
|
194
|
+
method: "GET",
|
|
195
|
+
headers: { accept: "application/json" },
|
|
196
|
+
timeout: timeoutMs,
|
|
197
|
+
...(local ? { lookup: loopbackLookup } : {}),
|
|
198
|
+
},
|
|
199
|
+
(response) => {
|
|
200
|
+
let body = "";
|
|
201
|
+
response.setEncoding("utf8");
|
|
202
|
+
response.on("data", (chunk: string) => {
|
|
203
|
+
if (body.length < 4_096) {
|
|
204
|
+
body += chunk;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
response.on("end", () =>
|
|
208
|
+
resolve({ status: response.statusCode ?? 0, body: body.slice(0, 4_096) }),
|
|
209
|
+
);
|
|
210
|
+
response.on("error", reject);
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
asked.on("timeout", () => asked.destroy(new Error(`no answer within ${timeoutMs / 1000} s`)));
|
|
214
|
+
asked.on("error", (error: NodeJS.ErrnoException) =>
|
|
215
|
+
reject(
|
|
216
|
+
new Error(error.code === undefined ? error.message : `${error.code} ${error.message}`),
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
asked.end();
|
|
220
|
+
});
|
|
221
|
+
}
|
package/src/platform/web.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { ColorScheme } from "../config/schema.ts";
|
|
2
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
2
3
|
import { postMessageTransport } from "../transport/post-message.ts";
|
|
3
4
|
import type { PlatformHost, Surface, SurfaceSpec } from "./types.ts";
|
|
4
5
|
|
|
6
|
+
/** A frame-status message (`porthole/notifications/frame-status`), by its method alone. */
|
|
7
|
+
const isFrameStatus = (data: unknown): boolean =>
|
|
8
|
+
data !== null &&
|
|
9
|
+
typeof data === "object" &&
|
|
10
|
+
(data as { jsonrpc?: unknown }).jsonrpc === "2.0" &&
|
|
11
|
+
(data as { method?: unknown }).method === FRAME_STATUS_METHOD;
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* The web platform: an `<iframe>` whose policy attributes are set before its document is given,
|
|
7
15
|
* talking over `postMessage`. Its first load is the document it was given; any later load is the
|
|
@@ -54,7 +62,17 @@ export const webPlatform: PlatformHost = {
|
|
|
54
62
|
container.append(frame);
|
|
55
63
|
|
|
56
64
|
const view = container.ownerDocument.defaultView ?? window;
|
|
57
|
-
|
|
65
|
+
// A served frame may pass through the host's own pages (a redirect entry's, P068): what they
|
|
66
|
+
// say of the frame's status is heard too, and nothing else from there.
|
|
67
|
+
const own = view.origin;
|
|
68
|
+
const transport = postMessageTransport(
|
|
69
|
+
() => frame.contentWindow,
|
|
70
|
+
origin,
|
|
71
|
+
view,
|
|
72
|
+
origin === "null" || own === "null" || own === origin
|
|
73
|
+
? undefined
|
|
74
|
+
: { origin: own, accept: isFrameStatus },
|
|
75
|
+
);
|
|
58
76
|
return {
|
|
59
77
|
transport,
|
|
60
78
|
element: frame,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { maybeSameSite, parseOrigin } from "../isolation/served.ts";
|
|
2
|
+
import type { ContentDomain } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A content domain from deployment settings (P066): `content.example.com`, `content.localhost:8790`
|
|
6
|
+
* or `https://content.example.com`, parsed, and checked against the app's own origins. Porthole
|
|
7
|
+
* never serves a content domain that holds an app origin or may share its site: the host kit
|
|
8
|
+
* either throws at startup or (with its opaque fallback) reports it as permanently unhealthy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ContentDomainInfo {
|
|
12
|
+
/** The registrable domain content is served under (`content.example.com`). */
|
|
13
|
+
readonly domain: string;
|
|
14
|
+
/** `http` only on loopback (`localhost`, `*.localhost`). */
|
|
15
|
+
readonly scheme: "https" | "http";
|
|
16
|
+
/** A port other than the scheme's default. */
|
|
17
|
+
readonly port?: number;
|
|
18
|
+
/** The frame source every content origin matches (`https://*.content.example.com`). */
|
|
19
|
+
readonly source: string;
|
|
20
|
+
/** The origin a label is served on. */
|
|
21
|
+
originOf(label: string): string;
|
|
22
|
+
/** Whether a host (or hostname) is under the content domain (the apex included). */
|
|
23
|
+
isContentHost(host: string): boolean;
|
|
24
|
+
/** The part of a hostname under the content domain (`a-1`, `x.f`), or null. */
|
|
25
|
+
labelOf(host: string): string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ContentDomainProblem = "invalid" | "clash" | "same-site";
|
|
29
|
+
|
|
30
|
+
/** Why a content domain can't be served; `code` says which rule (P066). */
|
|
31
|
+
export class ContentDomainError extends Error {
|
|
32
|
+
readonly code: ContentDomainProblem;
|
|
33
|
+
|
|
34
|
+
constructor(code: ContentDomainProblem, message: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ContentDomainError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HOST = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
42
|
+
const LOOPBACK = (host: string) => host === "localhost" || host.endsWith(".localhost");
|
|
43
|
+
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/u;
|
|
44
|
+
|
|
45
|
+
/** A hostname as it compares: lowercase, no port, no trailing dot. */
|
|
46
|
+
export function hostnameOf(host: string): string {
|
|
47
|
+
const lower = host.trim().toLowerCase();
|
|
48
|
+
const bare = lower.startsWith("[")
|
|
49
|
+
? lower.replace(/(\])(:\d+)?$/u, "$1")
|
|
50
|
+
: lower.replace(/:\d+$/u, "");
|
|
51
|
+
return bare.replace(/\.$/u, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses a content domain. `null` for an empty setting (content off). Throws a
|
|
56
|
+
* `ContentDomainError` for one that isn't a domain, or that holds or may share a site with one of
|
|
57
|
+
* `appOrigins` (the clash rules, named in the message).
|
|
58
|
+
*/
|
|
59
|
+
export function contentDomain(
|
|
60
|
+
input: string | null | undefined,
|
|
61
|
+
options: { readonly appOrigins: readonly string[] },
|
|
62
|
+
): ContentDomainInfo | null {
|
|
63
|
+
const raw = (input ?? "").trim().toLowerCase().replace(/\/+$/u, "");
|
|
64
|
+
if (raw === "") {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const match = /^(?:(https?):\/\/)?([^/:]+)(?::(\d{1,5}))?$/u.exec(raw);
|
|
68
|
+
const host = match?.[2]?.replace(/^\.+|\.+$/gu, "") ?? "";
|
|
69
|
+
if (
|
|
70
|
+
match === null ||
|
|
71
|
+
!HOST.test(host) ||
|
|
72
|
+
IPV4.test(host) ||
|
|
73
|
+
(!host.includes(".") && host !== "localhost")
|
|
74
|
+
) {
|
|
75
|
+
throw new ContentDomainError(
|
|
76
|
+
"invalid",
|
|
77
|
+
`The content domain ${JSON.stringify(input)} isn't a domain: give host[:port] (content.example.com, content.localhost:8790)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const loopback = LOOPBACK(host);
|
|
81
|
+
const scheme =
|
|
82
|
+
match[1] === undefined ? (loopback ? "http" : "https") : (match[1] as "http" | "https");
|
|
83
|
+
if (scheme === "http" && !loopback) {
|
|
84
|
+
throw new ContentDomainError(
|
|
85
|
+
"invalid",
|
|
86
|
+
`The content domain ${host} is served over http, which only loopback may use: serve it over https`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const portNumber = match[3] === undefined ? undefined : Number(match[3]);
|
|
90
|
+
if (portNumber !== undefined && (portNumber < 1 || portNumber > 65_535)) {
|
|
91
|
+
throw new ContentDomainError("invalid", `The content domain's port ${match[3]} isn't a port`);
|
|
92
|
+
}
|
|
93
|
+
const port = portNumber === (scheme === "https" ? 443 : 80) ? undefined : portNumber;
|
|
94
|
+
const portPart = port === undefined ? "" : `:${port}`;
|
|
95
|
+
const info: ContentDomainInfo = {
|
|
96
|
+
domain: host,
|
|
97
|
+
scheme,
|
|
98
|
+
...(port === undefined ? {} : { port }),
|
|
99
|
+
source: `${scheme}://*.${host}${portPart}`,
|
|
100
|
+
originOf: (label) => `${scheme}://${label}.${host}${portPart}`,
|
|
101
|
+
isContentHost: (each) => {
|
|
102
|
+
const name = hostnameOf(each);
|
|
103
|
+
return name === host || name.endsWith(`.${host}`);
|
|
104
|
+
},
|
|
105
|
+
labelOf: (each) => {
|
|
106
|
+
const name = hostnameOf(each);
|
|
107
|
+
return name.endsWith(`.${host}`) ? name.slice(0, -host.length - 1) : null;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const problem = clashOf(info, options.appOrigins);
|
|
111
|
+
if (problem !== null) {
|
|
112
|
+
throw problem;
|
|
113
|
+
}
|
|
114
|
+
return info;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The first clash between a content domain and the app's origins, as an error; null if none. */
|
|
118
|
+
export function clashOf(
|
|
119
|
+
info: ContentDomainInfo,
|
|
120
|
+
appOrigins: readonly string[],
|
|
121
|
+
): ContentDomainError | null {
|
|
122
|
+
// A label of the content domain, as a scope's would be: what "same site" is judged against.
|
|
123
|
+
const sample = `a1b2c3d4e5f6g7h8i9j0k1l2.${info.domain}`;
|
|
124
|
+
for (const origin of appOrigins) {
|
|
125
|
+
// A desktop app's own scheme (ferry://app) is never the same site as web content.
|
|
126
|
+
const parsed = parseOrigin(origin);
|
|
127
|
+
if (parsed === null) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (info.isContentHost(parsed.host)) {
|
|
131
|
+
return new ContentDomainError(
|
|
132
|
+
"clash",
|
|
133
|
+
`The content domain ${info.domain} holds the app's own origin ${origin}: content there could read the app's cookies and storage. Serve content from a domain the app uses for nothing else`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (maybeSameSite(parsed.host, sample)) {
|
|
137
|
+
return new ContentDomainError(
|
|
138
|
+
"same-site",
|
|
139
|
+
`The content domain ${info.domain} may be the same site as the app's origin ${origin}: serve content from a registrable domain the app uses for nothing else`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The `ContentDomain` `createServer` takes, from parsed settings. */
|
|
147
|
+
export function serverDomain(
|
|
148
|
+
info: ContentDomainInfo,
|
|
149
|
+
naming: Pick<ContentDomain, "name" | "reserved">,
|
|
150
|
+
): ContentDomain {
|
|
151
|
+
return {
|
|
152
|
+
domain: info.domain,
|
|
153
|
+
scheme: info.scheme,
|
|
154
|
+
...(info.port === undefined ? {} : { port: info.port }),
|
|
155
|
+
...(naming.name === undefined ? {} : { name: naming.name }),
|
|
156
|
+
...(naming.reserved === undefined ? {} : { reserved: naming.reserved }),
|
|
157
|
+
};
|
|
158
|
+
}
|