nexus-shared 3.1.0 → 3.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/CHANGELOG.md +704 -446
- package/LICENSE +21 -21
- package/README.md +44 -5
- package/dist/Client.Index.d.ts +3 -0
- package/dist/Client.Index.js +3 -0
- package/dist/Components/Forms/Crud.js +322 -161
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudParts.js +2 -1
- package/dist/Components/Forms/Form.js +4 -1
- package/dist/Components/Inputs/CodeBox.d.ts +69 -0
- package/dist/Components/Inputs/CodeBox.js +316 -0
- package/dist/Components/Inputs/DatePicker.js +4 -1
- package/dist/Components/Inputs/DateRangePicker.js +4 -1
- package/dist/Components/Inputs/DateTimePicker.js +4 -1
- package/dist/Components/Inputs/Dropdown.js +4 -1
- package/dist/Components/Inputs/InputRenderer.js +3 -0
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/TextArea.js +4 -1
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +55 -5
- package/dist/Components/Inputs/TreeView.js +372 -23
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +63 -21
- package/dist/Components/Viewers/DataTableParts.d.ts +26 -8
- package/dist/Components/Viewers/DataTableParts.js +39 -10
- package/dist/Helpers/ApiModules.d.ts +3 -34
- package/dist/Helpers/ApiModules.js +3 -3
- package/dist/Helpers/ApiRoutes.d.ts +68 -26
- package/dist/Helpers/ApiRoutes.js +31 -27
- package/dist/Helpers/ArrayHelpers.d.ts +0 -0
- package/dist/Helpers/ArrayHelpers.js +2 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +94 -4
- package/dist/Helpers/CrudBackend.js +168 -9
- package/dist/Helpers/CrudColumns.d.ts +51 -0
- package/dist/Helpers/CrudColumns.js +69 -0
- package/dist/Helpers/CrudHelpers.d.ts +50 -5
- package/dist/Helpers/CrudHelpers.js +109 -12
- package/dist/Helpers/InputParamsHelpers.js +8 -0
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/RequestCache.d.ts +35 -0
- package/dist/Helpers/RequestCache.js +113 -0
- package/dist/Helpers/TableExport.js +12 -12
- package/dist/Helpers/TableHelpers.d.ts +1 -1
- package/dist/Helpers/TableHelpers.js +7 -2
- package/dist/Helpers/TableJoins.d.ts +70 -0
- package/dist/Helpers/TableJoins.js +250 -0
- package/dist/Helpers/TableStore.d.ts +5 -0
- package/dist/Helpers/TableStore.js +9 -0
- package/dist/Helpers/TreeStore.d.ts +225 -11
- package/dist/Helpers/TreeStore.js +683 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +47 -9
- package/dist/Interfaces/FormInterfaces.d.ts +4 -1
- package/dist/Interfaces/InputInterfaces.d.ts +24 -0
- package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
- package/dist/Interfaces/PreferenceInterfaces.d.ts +8 -0
- package/dist/Interfaces/PreferenceInterfaces.js +2 -5
- package/dist/Interfaces/TableInterfaces.d.ts +86 -4
- package/dist/Services/ApiProxy.d.ts +36 -1
- package/dist/Services/ApiProxy.js +182 -52
- package/dist/Services/ApiProxyRules.d.ts +203 -0
- package/dist/Services/ApiProxyRules.js +426 -0
- package/dist/Services/JoinSources.d.ts +48 -0
- package/dist/Services/JoinSources.js +74 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +4 -0
- package/dist/Shared.Index.js +4 -0
- package/package.json +87 -85
- package/src/Styles/Nexus.Avatar.css +79 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +180 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +343 -343
- package/src/Styles/Nexus.Crud.css +168 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +725 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Form.css +279 -279
- package/src/Styles/Nexus.Index.css +37 -37
- package/src/Styles/Nexus.Input.css +196 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +237 -237
- package/src/Styles/Nexus.Popup.css +724 -724
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +432 -432
- package/src/Styles/Nexus.Table.css +31 -30
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +194 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +89 -89
- package/src/Styles/Nexus.Tree.css +500 -431
|
@@ -2,71 +2,160 @@ import { DEFAULT_API_TIMEOUT, isFrameworkError } from "../Helpers/ApiClient.js";
|
|
|
2
2
|
import { getApiModules } from "../Helpers/ApiModules.js";
|
|
3
3
|
import { joinUrl } from "../Helpers/ApiRoutes.js";
|
|
4
4
|
import { buildMessage } from "../Helpers/MessageBuilder.js";
|
|
5
|
-
import {
|
|
5
|
+
import { BODY_HEADERS, DEFAULT_PROXY_BODY_LIMIT, DEFAULT_PROXY_REDIRECTS, ERROR_BODY_LIMIT, filterResponseHeaders, forwardRequestHeaders, harden, isCrossSiteCall, isRedirect, isUnsafeSegment, lastForwardedAddress, logSnippet, logUrl, methodAfterRedirect, nexusFailure, ownOrigin, PROXY_REFUSALS, readBody, readIpAddress, redirectInsideModule, whyHidden, } from "./ApiProxyRules.js";
|
|
6
|
+
import { serverAppOrigin, serverModuleRoot } from "./ServerApi.js";
|
|
7
|
+
export { DEFAULT_PROXY_BODY_LIMIT, DEFAULT_PROXY_REDIRECTS, DEFAULT_PROXY_RESPONSE_HEADERS, lastForwardedAddress, nexusFailure, PROXY_REFUSALS, readIpAddress } from "./ApiProxyRules.js";
|
|
6
8
|
const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
9
|
+
/** Statuses whose answer has no body. */
|
|
10
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
11
|
+
/** What a call does, for the plain words of a refusal: a GET loads, a DELETE deletes, the rest save. */
|
|
12
|
+
function actionOf(method) {
|
|
13
|
+
if (method === "GET" || method === "HEAD")
|
|
14
|
+
return "load";
|
|
15
|
+
return method === "DELETE" ? "delete" : "save";
|
|
16
|
+
}
|
|
17
|
+
/** Whether an answer reaches the browser as it came, without being read first: a success, or a 304. */
|
|
18
|
+
function passesUnread(status) {
|
|
19
|
+
return (status >= 200 && status < 300) || status === 304;
|
|
20
|
+
}
|
|
21
|
+
/** Why a `fetch` failed, from its cause ("ECONNREFUSED", "DEPTH_ZERO_SELF_SIGNED_CERT"), with what to do about a certificate. */
|
|
22
|
+
function describeFetchError(error) {
|
|
23
|
+
const cause = error?.cause;
|
|
24
|
+
let text = error instanceof Error ? error.message : String(error);
|
|
25
|
+
if (typeof cause?.code === "string")
|
|
26
|
+
text = cause.code;
|
|
27
|
+
else if (typeof cause?.message === "string")
|
|
28
|
+
text = cause.message;
|
|
29
|
+
if (!/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS/i.test(text))
|
|
30
|
+
return text;
|
|
31
|
+
return `${text}: this server does not trust the backend's certificate. Start Node.js with NODE_USE_SYSTEM_CA=1 (or NODE_EXTRA_CA_CERTS) so it trusts the certificates this machine trusts, or give the module a root it trusts`;
|
|
32
|
+
}
|
|
33
|
+
/** A redirect's target for the log, without its query. */
|
|
34
|
+
function logLocation(location, from) {
|
|
35
|
+
try {
|
|
36
|
+
return logUrl(new URL(location, from));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "a Location that is not a URL";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function logProblem(problem) {
|
|
43
|
+
console.error(`[apiProxy] ${problem.call}: ${problem.messageCode}, ${problem.detail}`);
|
|
44
|
+
}
|
|
9
45
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
46
|
+
* The app's address as the backend is told it in X-Forwarded-Host and -Proto: the configured one
|
|
47
|
+
* (`configureServerApi({ origin })`, URL_CURRENT), else this request's, which Next.js builds from its own host and port
|
|
48
|
+
* (never the Host header) but with the protocol X-Forwarded-Proto names. Never where a call goes: that is `ownOrigin`.
|
|
12
49
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const relative = target.pathname.startsWith(`${base}/`) ? target.pathname.slice(base.length + 1) : target.pathname.replace(/^\/+/, "");
|
|
25
|
-
return `${joinUrl(joinUrl(proxyPath, module), relative)}${target.search}`;
|
|
50
|
+
async function appOriginOf(incoming) {
|
|
51
|
+
try {
|
|
52
|
+
const configured = await serverAppOrigin();
|
|
53
|
+
if (configured)
|
|
54
|
+
return new URL(configured);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (isFrameworkError(error))
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return incoming;
|
|
26
61
|
}
|
|
27
62
|
/**
|
|
28
63
|
* Makes the route handlers of the proxy. Only the modules' configured roots can be reached: a path cannot climb out of
|
|
29
64
|
* its module (`..` is refused), and the query goes on unchanged.
|
|
30
65
|
*/
|
|
31
66
|
export function createApiProxy(options = {}) {
|
|
67
|
+
const maxBodySize = options.maxBodySize ?? DEFAULT_PROXY_BODY_LIMIT;
|
|
68
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_PROXY_REDIRECTS;
|
|
69
|
+
const timeout = options.timeout ?? DEFAULT_API_TIMEOUT;
|
|
70
|
+
/** A refusal of the proxy's own, in the app's envelope (Nexus's by default). */
|
|
71
|
+
function refuse(messageCode, message, status = PROXY_REFUSALS[messageCode].status) {
|
|
72
|
+
return harden((options.failure ?? nexusFailure)({ status, message, messageCode, errorCode: PROXY_REFUSALS[messageCode].errorCode }));
|
|
73
|
+
}
|
|
74
|
+
function report(problem) {
|
|
75
|
+
try {
|
|
76
|
+
(options.onProblem ?? logProblem)(problem);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// A log that fails must not change the answer.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
32
82
|
async function handle(request, context) {
|
|
33
83
|
const method = request.method.toUpperCase();
|
|
34
|
-
if (!METHODS.has(method))
|
|
35
|
-
return failure(405, buildMessage("reason", { status: 405 }), "METHOD_NOT_ALLOWED");
|
|
36
84
|
const { module: key, path = [] } = await context.params;
|
|
85
|
+
// The call as the log names it: the module and path the browser asked for, never the query. Encoded, so a line
|
|
86
|
+
// break the browser put in the path cannot start a line of its own in the log.
|
|
87
|
+
const call = `${method} ${[key, ...path].map(encodeURIComponent).join("/")}`;
|
|
88
|
+
const action = actionOf(method);
|
|
89
|
+
if (!METHODS.has(method)) {
|
|
90
|
+
const refusal = refuse("METHOD_NOT_ALLOWED", buildMessage("reason", { status: 405 }));
|
|
91
|
+
refusal.headers.set("Allow", [...METHODS].join(", "));
|
|
92
|
+
return refusal;
|
|
93
|
+
}
|
|
94
|
+
if (!options.allowCrossSite && isCrossSiteCall(request.headers, method))
|
|
95
|
+
return refuse("CROSS_SITE_REFUSED", buildMessage("reason", { action, status: 403 }));
|
|
37
96
|
// Asked on every request, so the app's modules count even when they were registered after this route was made.
|
|
38
97
|
const modules = getApiModules();
|
|
39
98
|
const info = modules.info(key);
|
|
40
99
|
if (!info || (options.allow && !options.allow.includes(info.module)))
|
|
41
|
-
return
|
|
100
|
+
return refuse("MODULE_NOT_FOUND", `No API module is named "${key}".`);
|
|
42
101
|
if (!modules.proxies(info.module))
|
|
43
|
-
return
|
|
44
|
-
if (path.some(
|
|
45
|
-
return
|
|
102
|
+
return refuse("MODULE_NOT_PROXIED", `The ${info.label} API is not available at this address.`);
|
|
103
|
+
if (path.some(isUnsafeSegment))
|
|
104
|
+
return refuse("INVALID_PATH", "The API path is not valid.");
|
|
105
|
+
// The app's address as the backend is told it, in X-Forwarded-Host and -Proto. The configured one comes first:
|
|
106
|
+
// Next.js takes a request URL's protocol from X-Forwarded-Proto, which anyone can send.
|
|
46
107
|
const incoming = new URL(request.url);
|
|
108
|
+
const app = await appOriginOf(incoming);
|
|
109
|
+
// The module's root. One that is a path on this app is called at this server's own host and port, never at the
|
|
110
|
+
// configured origin, which an app may build from the Host header. What is wrong with a root (an environment
|
|
111
|
+
// variable's name, an address) is for the log only.
|
|
47
112
|
let root;
|
|
48
113
|
try {
|
|
49
|
-
root = new URL(await serverModuleRoot(info.module, incoming
|
|
114
|
+
root = new URL(await serverModuleRoot(info.module, ownOrigin(incoming)));
|
|
115
|
+
if (root.protocol !== "http:" && root.protocol !== "https:")
|
|
116
|
+
throw new Error(`The ${info.label} API's root is not an http or https address.`);
|
|
50
117
|
}
|
|
51
118
|
catch (error) {
|
|
52
119
|
if (isFrameworkError(error))
|
|
53
120
|
throw error;
|
|
54
|
-
|
|
121
|
+
report({ messageCode: "MODULE_NOT_CONFIGURED", call, detail: error instanceof Error ? error.message : String(error) });
|
|
122
|
+
return refuse("MODULE_NOT_CONFIGURED", `The ${info.label} API is not set up on this server.`);
|
|
55
123
|
}
|
|
56
124
|
const target = new URL(joinUrl(root.toString(), path.map(encodeURIComponent).join("/")));
|
|
57
125
|
target.search = incoming.search;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
126
|
+
// The body, read whole so a 307 or 308 can send it again. A declared length over the limit is refused before a
|
|
127
|
+
// byte is read; an undeclared one is counted as it arrives.
|
|
128
|
+
let body;
|
|
129
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
130
|
+
let read;
|
|
131
|
+
try {
|
|
132
|
+
read = Number(request.headers.get("content-length")) > maxBodySize ? null : await readBody(request.body, maxBodySize);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (isFrameworkError(error))
|
|
136
|
+
throw error;
|
|
137
|
+
return refuse("REQUEST_UNREADABLE", buildMessage("reason", { status: 400 }));
|
|
138
|
+
}
|
|
139
|
+
if (!read)
|
|
140
|
+
return refuse("REQUEST_TOO_LARGE", buildMessage("reason", { action, status: 413 }));
|
|
141
|
+
body = read;
|
|
142
|
+
}
|
|
143
|
+
// The headers: the browser's, less this hop's, the app's cookies and everything the browser says about where it
|
|
144
|
+
// came from; then who the client is and where the app is, as the app knows them; then the app's own and the token.
|
|
145
|
+
const headers = forwardRequestHeaders(request.headers);
|
|
146
|
+
const client = readIpAddress(options.clientAddress ? await options.clientAddress(request) : lastForwardedAddress(request.headers));
|
|
147
|
+
if (client)
|
|
148
|
+
headers.set("X-Forwarded-For", client);
|
|
149
|
+
headers.set("X-Forwarded-Host", app.host);
|
|
150
|
+
headers.set("X-Forwarded-Proto", app.protocol.replace(":", ""));
|
|
63
151
|
for (const [name, value] of Object.entries((await options.headers?.(request)) ?? {}))
|
|
64
152
|
headers.set(name, value);
|
|
65
153
|
const token = await options.token?.(request);
|
|
66
154
|
if (token)
|
|
67
155
|
headers.set("Authorization", /^bearer /i.test(token) ? token : `Bearer ${token}`);
|
|
156
|
+
// The token as the backend got it, to keep it out of the log and to spot an error page that repeats it.
|
|
157
|
+
const sentToken = token ? token.replace(/^bearer\s+/i, "") : undefined;
|
|
68
158
|
const controller = new AbortController();
|
|
69
|
-
const timeout = options.timeout ?? DEFAULT_API_TIMEOUT;
|
|
70
159
|
let timedOut = false;
|
|
71
160
|
const timer = setTimeout(() => {
|
|
72
161
|
timedOut = true;
|
|
@@ -75,37 +164,78 @@ export function createApiProxy(options = {}) {
|
|
|
75
164
|
// The browser left: stop waiting for the backend.
|
|
76
165
|
const leave = () => controller.abort();
|
|
77
166
|
request.signal?.addEventListener("abort", leave, { once: true });
|
|
167
|
+
const send = options.fetch ?? globalThis.fetch;
|
|
168
|
+
let url = target;
|
|
169
|
+
let hopMethod = method;
|
|
170
|
+
let hopBody = body;
|
|
78
171
|
let answer;
|
|
172
|
+
// An error answer, read whole (up to a limit) to see whether it may reach the browser as it came; null when larger.
|
|
173
|
+
let errorBody;
|
|
79
174
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
redirect:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
175
|
+
const ask = () => send(url, { method: hopMethod, headers, body: hopBody, redirect: "manual", cache: "no-store", signal: controller.signal });
|
|
176
|
+
answer = await ask();
|
|
177
|
+
let hops = 0;
|
|
178
|
+
while (isRedirect(answer.status)) {
|
|
179
|
+
// A redirect never reaches the browser: followed here while it stays inside the module, refused otherwise.
|
|
180
|
+
await answer.body?.cancel().catch(() => undefined);
|
|
181
|
+
const location = answer.headers.get("location");
|
|
182
|
+
const next = location ? redirectInsideModule(location, url, root) : undefined;
|
|
183
|
+
if (!next) {
|
|
184
|
+
const where = location ? `to ${logLocation(location, url)}, outside the module's root ${logUrl(root)}` : "with no Location";
|
|
185
|
+
report({ messageCode: "BACKEND_REDIRECT_REFUSED", call, detail: `${logUrl(url)} answered ${answer.status} ${where}` });
|
|
186
|
+
return refuse("BACKEND_REDIRECT_REFUSED", buildMessage("reason", { status: 502 }));
|
|
187
|
+
}
|
|
188
|
+
if (hops >= maxRedirects) {
|
|
189
|
+
report({ messageCode: "BACKEND_REDIRECT_LIMIT", call, detail: `more than ${maxRedirects} redirects; the last from ${logUrl(url)} to ${logUrl(next)}` });
|
|
190
|
+
return refuse("BACKEND_REDIRECT_LIMIT", buildMessage("reason", { status: 502 }));
|
|
191
|
+
}
|
|
192
|
+
const nextMethod = methodAfterRedirect(answer.status, hopMethod);
|
|
193
|
+
if (nextMethod !== hopMethod) {
|
|
194
|
+
// A 303, or a 301 or 302 after a POST: the request becomes a GET and leaves its body behind.
|
|
195
|
+
hopBody = undefined;
|
|
196
|
+
for (const name of BODY_HEADERS)
|
|
197
|
+
headers.delete(name);
|
|
198
|
+
}
|
|
199
|
+
hopMethod = nextMethod;
|
|
200
|
+
url = next;
|
|
201
|
+
hops++;
|
|
202
|
+
answer = await ask();
|
|
203
|
+
}
|
|
204
|
+
if (hopMethod !== "HEAD" && !passesUnread(answer.status))
|
|
205
|
+
errorBody = await readBody(answer.body, ERROR_BODY_LIMIT);
|
|
88
206
|
}
|
|
89
207
|
catch (error) {
|
|
90
208
|
if (isFrameworkError(error))
|
|
91
209
|
throw error;
|
|
92
|
-
if (timedOut)
|
|
93
|
-
|
|
94
|
-
|
|
210
|
+
if (timedOut) {
|
|
211
|
+
report({ messageCode: "BACKEND_TIMEOUT", call, detail: `${logUrl(url)} did not answer within ${timeout} ms` });
|
|
212
|
+
return refuse("BACKEND_TIMEOUT", buildMessage("reason", { timeout: true }));
|
|
213
|
+
}
|
|
214
|
+
// When the browser left, nobody reads the answer, and nothing failed on the backend's side.
|
|
215
|
+
if (!request.signal?.aborted)
|
|
216
|
+
report({ messageCode: "BACKEND_UNREACHABLE", call, detail: `${logUrl(url)}: ${describeFetchError(error)}` });
|
|
217
|
+
return refuse("BACKEND_UNREACHABLE", `The ${info.label} API cannot be reached. Try again in a moment.`);
|
|
95
218
|
}
|
|
96
219
|
finally {
|
|
97
|
-
// The timeout covers the wait for the answer to start
|
|
220
|
+
// The timeout covers the wait for the answer to start, its redirects and the reading of an error answer included;
|
|
221
|
+
// a long download then streams through.
|
|
98
222
|
clearTimeout(timer);
|
|
99
223
|
request.signal?.removeEventListener("abort", leave);
|
|
100
224
|
}
|
|
101
|
-
const
|
|
102
|
-
answer.headers.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
225
|
+
const status = answer.status;
|
|
226
|
+
const responseHeaders = filterResponseHeaders(answer.headers, { extra: options.responseHeaders, passCookies: options.passCookies });
|
|
227
|
+
if (errorBody === undefined) {
|
|
228
|
+
const noBody = hopMethod === "HEAD" || NULL_BODY_STATUSES.has(status);
|
|
229
|
+
return harden(new Response(noBody ? null : answer.body, { status, headers: responseHeaders }));
|
|
230
|
+
}
|
|
231
|
+
// An error answer passes as it came when it is empty, or JSON that says nothing about the backend; anything else
|
|
232
|
+
// is replaced with the failure envelope, in plain words for the status, and the real cause is logged.
|
|
233
|
+
const text = errorBody === null ? "" : new TextDecoder().decode(errorBody);
|
|
234
|
+
const reason = errorBody === null ? `it was over ${ERROR_BODY_LIMIT} bytes` : whyHidden(answer.headers.get("content-type") ?? "", text, sentToken);
|
|
235
|
+
if (reason === undefined)
|
|
236
|
+
return harden(new Response(text === "" ? null : errorBody, { status, headers: responseHeaders }));
|
|
237
|
+
report({ messageCode: "BACKEND_ERROR_PAGE", call, detail: `${logUrl(url)} answered ${status}, and ${reason}: ${logSnippet(text, sentToken)}` });
|
|
238
|
+
return refuse("BACKEND_ERROR_PAGE", buildMessage("reason", { action, status }), status);
|
|
109
239
|
}
|
|
110
240
|
return { GET: handle, HEAD: handle, POST: handle, PUT: handle, PATCH: handle, DELETE: handle };
|
|
111
241
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** The most bytes a request body may have, unless the app says otherwise (`maxBodySize`): 100 MB. */
|
|
2
|
+
export declare const DEFAULT_PROXY_BODY_LIMIT: number;
|
|
3
|
+
/** How many redirects inside a module the proxy follows for one call, unless the app says otherwise (`maxRedirects`). */
|
|
4
|
+
export declare const DEFAULT_PROXY_REDIRECTS = 3;
|
|
5
|
+
/** The most of an error answer the proxy reads to see what it is. A bigger one is never passed on. */
|
|
6
|
+
export declare const ERROR_BODY_LIMIT: number;
|
|
7
|
+
/**
|
|
8
|
+
* The browser's request headers the backend may see: all of them except the ones above, and except any that the
|
|
9
|
+
* request's own `Connection` header names (those belong to this hop too).
|
|
10
|
+
*/
|
|
11
|
+
export declare function forwardRequestHeaders(incoming: Headers): Headers;
|
|
12
|
+
/**
|
|
13
|
+
* An IP address as text, without the port some proxies add; `undefined` for anything else (a host name, "unknown", an
|
|
14
|
+
* obfuscated identifier, a zone such as `%eth0`). An IPv6 address is checked by the URL parser, which every runtime has,
|
|
15
|
+
* once its characters are known to be an address's: without that, text that closes the bracket itself and goes on
|
|
16
|
+
* (`::1]/,6.6.6.6`) parsed as an address followed by a path.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* readIpAddress(" 203.0.113.7:51000 ") // "203.0.113.7"
|
|
20
|
+
* readIpAddress("[2001:db8::1]:443") // "2001:db8::1"
|
|
21
|
+
* readIpAddress("unknown") // undefined
|
|
22
|
+
* readIpAddress("::1]/,6.6.6.6") // undefined
|
|
23
|
+
*/
|
|
24
|
+
export declare function readIpAddress(value: string | null | undefined): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* This server's own origin, for a module whose root is a path on the app (a pretend backend): the host and port of the
|
|
27
|
+
* request URL, over http. Next.js builds that URL from the host and port it listens on, never from the Host header, but
|
|
28
|
+
* takes its protocol from X-Forwarded-Proto, which anyone can send, so the protocol is not read from it. A server that
|
|
29
|
+
* listens on every address (`0.0.0.0`, `::`) is called on this machine's own, and the port is always the one Next.js
|
|
30
|
+
* wrote, even when the URL leaves it out as its protocol's default.
|
|
31
|
+
*
|
|
32
|
+
* Never the app's configured origin: an app may build that from a request's Host header, and then a client would choose
|
|
33
|
+
* which port on this machine the proxy calls, with the user's token.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ownOrigin(new URL("https://localhost:20016/api/app/drive/File/List")) // "http://localhost:20016"
|
|
37
|
+
* ownOrigin(new URL("http://0.0.0.0:3000/api/app/drive/File/List")) // "http://127.0.0.1:3000"
|
|
38
|
+
*/
|
|
39
|
+
export declare function ownOrigin(requestUrl: URL): string;
|
|
40
|
+
/**
|
|
41
|
+
* The client's address as the app received it: the **last** one in X-Forwarded-For, which is the one Next.js (from the
|
|
42
|
+
* connection) or the app's own reverse proxy in front of it wrote. The ones before it are what the client claims, and
|
|
43
|
+
* a client can claim anything. Behind two proxies of the app's own, or a CDN with a header of its own, the app passes
|
|
44
|
+
* `clientAddress` to the proxy instead. It reads anything with a `get`: a request's headers, or Next.js's `headers()`.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // A browser sent "X-Forwarded-For: 6.6.6.6"; the app's nginx appended the address it really came from.
|
|
48
|
+
* lastForwardedAddress(new Headers({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" })) // "203.0.113.7"
|
|
49
|
+
*/
|
|
50
|
+
export declare function lastForwardedAddress(headers: {
|
|
51
|
+
get(name: string): string | null;
|
|
52
|
+
}): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a browser sent this request from a page of another site, other than by following a link. Every current
|
|
55
|
+
* browser says where a request comes from in `Sec-Fetch-Site` and `Sec-Fetch-Mode`. The proxy turns the app's own
|
|
56
|
+
* session into the user's token, so a page on another site must not be able to call the backend through it (cross-site
|
|
57
|
+
* request forgery). A link followed from another site (an email, a chat) is a GET navigation and passes; a request
|
|
58
|
+
* without these headers (an older browser, a server, a tool) is no browser's cross-site call and passes too.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isCrossSiteCall(headers: Headers, method: string): boolean;
|
|
61
|
+
/** A path segment the proxy refuses: one that climbs (`.`, `..`, nothing but dots and spaces), or one holding a slash. */
|
|
62
|
+
export declare function isUnsafeSegment(segment: string): boolean;
|
|
63
|
+
/** Reads a body whole, up to `limit` bytes. `null` when it is longer: the rest is not read. */
|
|
64
|
+
export declare function readBody(stream: ReadableStream<Uint8Array> | null, limit: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
65
|
+
/** The backend's response headers that come back to the browser, and the only ones unless an app names more. */
|
|
66
|
+
export declare const DEFAULT_PROXY_RESPONSE_HEADERS: readonly string[];
|
|
67
|
+
export interface ResponseHeaderRules {
|
|
68
|
+
/** Header names to pass besides the defaults (any case). Never one of those that name the backend. */
|
|
69
|
+
extra?: readonly string[];
|
|
70
|
+
/** Pass the backend's Set-Cookie headers. */
|
|
71
|
+
passCookies?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/** The backend's response headers the browser may see: the allowlist and the app's own names, never one of the above. */
|
|
74
|
+
export declare function filterResponseHeaders(answer: Headers, rules?: ResponseHeaderRules): Headers;
|
|
75
|
+
export declare function isRedirect(status: number): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Where a backend's redirect leads, when it stays inside the module: on the root's own origin, or on the root's own
|
|
78
|
+
* host moved from http to https (a backend's HTTPS redirection, which picks its own https port), and under the root's
|
|
79
|
+
* path, whose casing may differ ("/App/Api" and "/app/api"). Anything else - another host or port, a move from https
|
|
80
|
+
* to http, a path outside the root, a URL with a user name in it - is `undefined`, and the proxy refuses it: following
|
|
81
|
+
* it would carry the user's token somewhere the app never named.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* // The module's root is http://localhost:10040/App/Api, and the proxy asked for .../App/Api/Region/ActiveListing:
|
|
85
|
+
* redirectInsideModule("https://localhost:10041/App/Api/Region/ActiveListing", asked, root) // followed: the https upgrade
|
|
86
|
+
* redirectInsideModule("/App/Api/Region/ActiveListing/", asked, root) // followed: a trailing slash
|
|
87
|
+
* redirectInsideModule("https://sso.example.com/login?next=/App/Api", asked, root) // undefined: another host
|
|
88
|
+
*/
|
|
89
|
+
export declare function redirectInsideModule(location: string, from: URL, root: URL): URL | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The method of the request after a redirect, as the Fetch standard follows one: a 303 asks for a GET (a HEAD stays a
|
|
92
|
+
* HEAD), a 301 or 302 turns a POST into a GET, and a 307 or 308 keeps the method and the body.
|
|
93
|
+
*/
|
|
94
|
+
export declare function methodAfterRedirect(status: number, method: string): string;
|
|
95
|
+
/** Headers that describe a request's body: they go with the body when a redirect turns the request into a GET. */
|
|
96
|
+
export declare const BODY_HEADERS: readonly string[];
|
|
97
|
+
/**
|
|
98
|
+
* Why an error answer cannot reach the browser as it came, or `undefined` when it can. An empty answer passes (there is
|
|
99
|
+
* nothing in it), and so does JSON - the backend's envelope, or problem details - unless it is a stack trace or repeats
|
|
100
|
+
* the user's token (a page that lists the request's headers). Anything else (an HTML page, text, XML) is the backend
|
|
101
|
+
* talking about itself.
|
|
102
|
+
*/
|
|
103
|
+
export declare function whyHidden(contentType: string, text: string, token?: string | null): string | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* A short, one-line piece of an error answer for the server's log: the user's token, styles, scripts and tags taken
|
|
106
|
+
* out, spaces collapsed.
|
|
107
|
+
*/
|
|
108
|
+
export declare function logSnippet(text: string, token?: string | null, length?: number): string;
|
|
109
|
+
/** A URL for the server's log: its origin and path, never its query (it may hold what a user searched for, or a key). */
|
|
110
|
+
export declare function logUrl(url: URL): string;
|
|
111
|
+
/**
|
|
112
|
+
* The proxy's own refusals: each code is answered from one place in `ApiProxy.ts`, with its HTTP status and its kind
|
|
113
|
+
* as the Nexus backend numbers kinds (`EErrorCodes`, where a kind's category is its number divided by 20).
|
|
114
|
+
*/
|
|
115
|
+
export declare const PROXY_REFUSALS: {
|
|
116
|
+
/** A method other than GET, HEAD, POST, PUT, PATCH and DELETE. RequestInvalid. */
|
|
117
|
+
readonly METHOD_NOT_ALLOWED: {
|
|
118
|
+
readonly status: 405;
|
|
119
|
+
readonly errorCode: 29;
|
|
120
|
+
};
|
|
121
|
+
/** A call from a page of another site. AccessDenied. */
|
|
122
|
+
readonly CROSS_SITE_REFUSED: {
|
|
123
|
+
readonly status: 403;
|
|
124
|
+
readonly errorCode: 60;
|
|
125
|
+
};
|
|
126
|
+
/** A module the app does not have. RequestInvalid. */
|
|
127
|
+
readonly MODULE_NOT_FOUND: {
|
|
128
|
+
readonly status: 404;
|
|
129
|
+
readonly errorCode: 29;
|
|
130
|
+
};
|
|
131
|
+
/** A module the browser calls directly, not through the proxy. RequestInvalid. */
|
|
132
|
+
readonly MODULE_NOT_PROXIED: {
|
|
133
|
+
readonly status: 404;
|
|
134
|
+
readonly errorCode: 29;
|
|
135
|
+
};
|
|
136
|
+
/** A path that climbs out of its module. RequestInvalid. */
|
|
137
|
+
readonly INVALID_PATH: {
|
|
138
|
+
readonly status: 400;
|
|
139
|
+
readonly errorCode: 29;
|
|
140
|
+
};
|
|
141
|
+
/** A body over the limit. TooLong. */
|
|
142
|
+
readonly REQUEST_TOO_LARGE: {
|
|
143
|
+
readonly status: 413;
|
|
144
|
+
readonly errorCode: 22;
|
|
145
|
+
};
|
|
146
|
+
/** A body that could not be read (the client left while sending it). RequestInvalid. */
|
|
147
|
+
readonly REQUEST_UNREADABLE: {
|
|
148
|
+
readonly status: 400;
|
|
149
|
+
readonly errorCode: 29;
|
|
150
|
+
};
|
|
151
|
+
/** A module without a usable root URL on this server. SettingMissing. */
|
|
152
|
+
readonly MODULE_NOT_CONFIGURED: {
|
|
153
|
+
readonly status: 500;
|
|
154
|
+
readonly errorCode: 120;
|
|
155
|
+
};
|
|
156
|
+
/** A backend that cannot be reached: down, refused, or a certificate this server does not trust. ServiceUnavailable. */
|
|
157
|
+
readonly BACKEND_UNREACHABLE: {
|
|
158
|
+
readonly status: 502;
|
|
159
|
+
readonly errorCode: 160;
|
|
160
|
+
};
|
|
161
|
+
/** A backend that did not start answering in time. ServiceTimeout. */
|
|
162
|
+
readonly BACKEND_TIMEOUT: {
|
|
163
|
+
readonly status: 504;
|
|
164
|
+
readonly errorCode: 161;
|
|
165
|
+
};
|
|
166
|
+
/** A redirect out of the module, or one with no usable Location. ServiceAnswerInvalid. */
|
|
167
|
+
readonly BACKEND_REDIRECT_REFUSED: {
|
|
168
|
+
readonly status: 502;
|
|
169
|
+
readonly errorCode: 163;
|
|
170
|
+
};
|
|
171
|
+
/** More redirects inside the module than the limit. ServiceAnswerInvalid. */
|
|
172
|
+
readonly BACKEND_REDIRECT_LIMIT: {
|
|
173
|
+
readonly status: 502;
|
|
174
|
+
readonly errorCode: 163;
|
|
175
|
+
};
|
|
176
|
+
/** An error page that cannot be passed on, answered with the backend's own status. ServiceRefused. */
|
|
177
|
+
readonly BACKEND_ERROR_PAGE: {
|
|
178
|
+
readonly status: 502;
|
|
179
|
+
readonly errorCode: 162;
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
export type ApiProxyCode = keyof typeof PROXY_REFUSALS;
|
|
183
|
+
/** A refusal of the proxy's own, for the app's `failure` builder. */
|
|
184
|
+
export interface ApiProxyFailure {
|
|
185
|
+
/** The HTTP status. */
|
|
186
|
+
status: number;
|
|
187
|
+
/** What the user reads, in plain words: never a host, a port, a setting, or anything else about the backend. */
|
|
188
|
+
message: string;
|
|
189
|
+
/** The proxy's code for this refusal, for a developer and for a page to react to: "BACKEND_REDIRECT_REFUSED". */
|
|
190
|
+
messageCode: ApiProxyCode;
|
|
191
|
+
/** The kind, as the Nexus backend numbers kinds: 163 is an answer that cannot be passed on. */
|
|
192
|
+
errorCode: number;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* A refusal as the Nexus backend answers one: `{ isSuccess: false, code, errorMessages: [{ message, messageCode,
|
|
196
|
+
* errorCode }] }`, with the one problem at the top level too, for readers of the older shape.
|
|
197
|
+
*/
|
|
198
|
+
export declare function nexusFailure(failure: ApiProxyFailure): Response;
|
|
199
|
+
/**
|
|
200
|
+
* Every answer the proxy gives: never sniffed into another type, and kept out of every cache unless the backend said
|
|
201
|
+
* how to cache it - the answer was fetched with the user's token, so a shared cache must never keep it for another user.
|
|
202
|
+
*/
|
|
203
|
+
export declare function harden(response: Response): Response;
|