nexus-shared 3.0.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -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 { serverModuleRoot } from "./ServerApi.js";
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
- /** Request headers that belong to this hop, or to the app (its cookies), not to the backend. */
8
- const REQUEST_HEADERS_TO_SKIP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "cookie", "te", "trailer", "proxy-authorization", "proxy-connection", "accept-encoding"]);
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
- * Response headers that belong to the backend's hop. `content-encoding` and `content-length` go too: `fetch` has already
11
- * decompressed the body, so passing them on would make the browser decode it twice.
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
- const RESPONSE_HEADERS_TO_SKIP = new Set(["connection", "transfer-encoding", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "upgrade", "content-encoding", "content-length"]);
14
- /** An answer the client reads like the backend's own. */
15
- function failure(status, message, messageCode) {
16
- return Response.json({ isSuccess: false, code: status, message, messageCode }, { status });
17
- }
18
- /** A backend redirect to its own host, pointed back through the proxy. */
19
- function rewriteLocation(location, root, module, proxyPath) {
20
- const target = new URL(location, root);
21
- if (target.host !== root.host)
22
- return location;
23
- const base = root.pathname.replace(/\/+$/, "");
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 failure(404, `No API module is named "${key}".`, "MODULE_NOT_FOUND");
100
+ return refuse("MODULE_NOT_FOUND", `No API module is named "${key}".`);
42
101
  if (!modules.proxies(info.module))
43
- return failure(404, `The ${info.label} API is not called through this app's proxy.`, "MODULE_NOT_PROXIED");
44
- if (path.some(segment => segment === "." || segment === ".." || /[\\/]/.test(segment)))
45
- return failure(400, "The API path is not valid.", "INVALID_PATH");
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.origin));
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
- return failure(500, error instanceof Error ? error.message : String(error), "MODULE_NOT_CONFIGURED");
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
- const headers = new Headers();
59
- request.headers.forEach((value, name) => {
60
- if (!REQUEST_HEADERS_TO_SKIP.has(name.toLowerCase()))
61
- headers.set(name, value);
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
- answer = await (options.fetch ?? globalThis.fetch)(target, {
81
- method,
82
- headers,
83
- body: method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer(),
84
- redirect: "manual",
85
- cache: "no-store",
86
- signal: controller.signal,
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
- return failure(504, buildMessage("reason", { timeout: true }), "BACKEND_TIMEOUT");
94
- return failure(502, `The ${info.label} API cannot be reached. Try again in a moment.`, "BACKEND_UNREACHABLE");
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; a long download then streams through.
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 responseHeaders = new Headers();
102
- answer.headers.forEach((value, name) => {
103
- const lower = name.toLowerCase();
104
- if (RESPONSE_HEADERS_TO_SKIP.has(lower))
105
- return;
106
- responseHeaders.append(name, lower === "location" ? rewriteLocation(value, root, info.key, modules.proxyPath) : value);
107
- });
108
- return new Response(method === "HEAD" ? null : answer.body, { status: answer.status, statusText: answer.statusText, headers: responseHeaders });
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;