nexus-shared 3.1.0 → 4.0.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 +2268 -446
- package/LICENSE +21 -21
- package/README.md +1705 -1084
- package/dist/Client.Index.d.ts +17 -0
- package/dist/Client.Index.js +17 -0
- package/dist/Components/Documents/DocumentFrame.d.ts +50 -0
- package/dist/Components/Documents/DocumentFrame.js +83 -0
- package/dist/Components/Documents/Menu.d.ts +4 -0
- package/dist/Components/Documents/Menu.js +40 -24
- package/dist/Components/Documents/PrintDocument.d.ts +95 -0
- package/dist/Components/Documents/PrintDocument.js +62 -0
- package/dist/Components/Documents/Tabs.d.ts +59 -0
- package/dist/Components/Documents/Tabs.js +70 -0
- package/dist/Components/Forms/ApiForm.js +19 -2
- package/dist/Components/Forms/Crud.d.ts +2 -2
- package/dist/Components/Forms/Crud.js +871 -377
- package/dist/Components/Forms/CrudActions.d.ts +136 -0
- package/dist/Components/Forms/CrudActions.js +184 -0
- package/dist/Components/Forms/CrudHistory.d.ts +66 -0
- package/dist/Components/Forms/CrudHistory.js +140 -0
- package/dist/Components/Forms/CrudNarrowPicker.d.ts +31 -0
- package/dist/Components/Forms/CrudNarrowPicker.js +180 -0
- package/dist/Components/Forms/CrudParts.d.ts +30 -2
- package/dist/Components/Forms/CrudParts.js +56 -5
- package/dist/Components/Forms/CrudRowForms.d.ts +26 -0
- package/dist/Components/Forms/CrudRowForms.js +114 -0
- package/dist/Components/Forms/CrudScopes.d.ts +28 -0
- package/dist/Components/Forms/CrudScopes.js +76 -0
- package/dist/Components/Forms/CrudTree.d.ts +90 -0
- package/dist/Components/Forms/CrudTree.js +363 -0
- package/dist/Components/Forms/CrudWorkspace.d.ts +63 -0
- package/dist/Components/Forms/CrudWorkspace.js +169 -0
- package/dist/Components/Forms/ExcelImport.js +3 -3
- package/dist/Components/Forms/Form.d.ts +4 -4
- package/dist/Components/Forms/Form.js +125 -16
- package/dist/Components/Forms/FormLeaveGuard.d.ts +44 -0
- package/dist/Components/Forms/FormLeaveGuard.js +65 -0
- package/dist/Components/Forms/RecordFlows.d.ts +50 -0
- package/dist/Components/Forms/RecordFlows.js +282 -0
- package/dist/Components/Forms/SubmitForm.d.ts +37 -2
- package/dist/Components/Forms/SubmitForm.js +195 -10
- package/dist/Components/Inputs/CheckBox.js +1 -1
- package/dist/Components/Inputs/ChoiceField.d.ts +21 -1
- package/dist/Components/Inputs/ChoiceField.js +28 -2
- 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.d.ts +13 -0
- package/dist/Components/Inputs/Dropdown.js +8 -5
- package/dist/Components/Inputs/DropdownParts.d.ts +3 -1
- package/dist/Components/Inputs/DropdownParts.js +16 -4
- package/dist/Components/Inputs/Field.d.ts +7 -1
- package/dist/Components/Inputs/Field.js +2 -2
- package/dist/Components/Inputs/GroupForm.d.ts +6 -6
- package/dist/Components/Inputs/GroupForm.js +10 -11
- package/dist/Components/Inputs/InputRenderer.d.ts +9 -6
- package/dist/Components/Inputs/InputRenderer.js +39 -31
- package/dist/Components/Inputs/NumberBox.js +4 -2
- package/dist/Components/Inputs/PictureInput.d.ts +41 -0
- package/dist/Components/Inputs/PictureInput.js +70 -0
- package/dist/Components/Inputs/RadioGroup.d.ts +16 -3
- package/dist/Components/Inputs/RadioGroup.js +8 -3
- package/dist/Components/Inputs/RowsInput.d.ts +1 -1
- package/dist/Components/Inputs/RowsInput.js +18 -3
- package/dist/Components/Inputs/TabularForm.d.ts +5 -5
- package/dist/Components/Inputs/TabularForm.js +29 -29
- package/dist/Components/Inputs/TextArea.d.ts +6 -0
- package/dist/Components/Inputs/TextArea.js +14 -2
- package/dist/Components/Inputs/TextBox.js +4 -1
- package/dist/Components/Inputs/TextBoxTypes.d.ts +6 -0
- package/dist/Components/Inputs/TextBoxTypes.js +17 -0
- package/dist/Components/Inputs/TimePicker.js +4 -1
- package/dist/Components/Inputs/TreeView.d.ts +107 -6
- package/dist/Components/Inputs/TreeView.js +513 -30
- package/dist/Components/Layouts/MessageBox.d.ts +15 -2
- package/dist/Components/Layouts/MessageBox.js +56 -7
- package/dist/Components/Layouts/Popup.d.ts +13 -6
- package/dist/Components/Layouts/Popup.js +37 -9
- package/dist/Components/Layouts/PopupDock.d.ts +3 -1
- package/dist/Components/Layouts/PopupDock.js +2 -1
- package/dist/Components/Layouts/PopupHost.js +5 -4
- package/dist/Components/Layouts/ThemePicker.d.ts +3 -2
- package/dist/Components/Layouts/ThemePicker.js +16 -4
- package/dist/Components/Viewers/DataTable.js +205 -61
- package/dist/Components/Viewers/DataTableFilters.d.ts +1 -1
- package/dist/Components/Viewers/DataTableFilters.js +17 -2
- package/dist/Components/Viewers/DataTableParts.d.ts +46 -12
- package/dist/Components/Viewers/DataTableParts.js +62 -21
- package/dist/Components/Viewers/OwnerFiles.d.ts +175 -0
- package/dist/Components/Viewers/OwnerFiles.js +589 -0
- package/dist/Components/Viewers/QrCodeImage.d.ts +22 -0
- package/dist/Components/Viewers/QrCodeImage.js +15 -0
- package/dist/Components/Viewers/RecordCards.d.ts +16 -0
- package/dist/Components/Viewers/RecordCards.js +509 -0
- 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/BusyHooks.d.ts +6 -0
- package/dist/Helpers/BusyHooks.js +39 -0
- package/dist/Helpers/CodeHelpers.d.ts +97 -0
- package/dist/Helpers/CodeHelpers.js +332 -0
- package/dist/Helpers/CrudBackend.d.ts +114 -6
- package/dist/Helpers/CrudBackend.js +214 -11
- package/dist/Helpers/CrudColumns.d.ts +98 -0
- package/dist/Helpers/CrudColumns.js +160 -0
- package/dist/Helpers/CrudDetailsTabs.d.ts +58 -0
- package/dist/Helpers/CrudDetailsTabs.js +126 -0
- package/dist/Helpers/CrudHelpers.d.ts +130 -6
- package/dist/Helpers/CrudHelpers.js +304 -12
- package/dist/Helpers/CrudNarrowPick.d.ts +83 -0
- package/dist/Helpers/CrudNarrowPick.js +157 -0
- package/dist/Helpers/CrudRowForms.d.ts +15 -0
- package/dist/Helpers/CrudRowForms.js +43 -0
- package/dist/Helpers/CrudScopes.d.ts +32 -0
- package/dist/Helpers/CrudScopes.js +79 -0
- package/dist/Helpers/DropdownHelpers.d.ts +3 -1
- package/dist/Helpers/DropdownHelpers.js +6 -2
- package/dist/Helpers/ExcelHelpers.d.ts +1 -1
- package/dist/Helpers/ExcelHelpers.js +12 -11
- package/dist/Helpers/FileAdapter.d.ts +78 -0
- package/dist/Helpers/FileAdapter.js +102 -0
- package/dist/Helpers/FilePrimaries.d.ts +48 -0
- package/dist/Helpers/FilePrimaries.js +138 -0
- package/dist/Helpers/FormConfirm.d.ts +32 -0
- package/dist/Helpers/FormConfirm.js +40 -0
- package/dist/Helpers/FormStore.d.ts +90 -5
- package/dist/Helpers/FormStore.js +318 -22
- package/dist/Helpers/InputConditions.d.ts +50 -0
- package/dist/Helpers/InputConditions.js +168 -0
- package/dist/Helpers/InputMemory.d.ts +106 -0
- package/dist/Helpers/InputMemory.js +385 -0
- package/dist/Helpers/InputNarrowing.d.ts +18 -0
- package/dist/Helpers/InputNarrowing.js +67 -0
- package/dist/Helpers/InputParamsHelpers.d.ts +44 -1
- package/dist/Helpers/InputParamsHelpers.js +162 -71
- package/dist/Helpers/MessageBuilder.js +2 -0
- package/dist/Helpers/NexusDriveAdapter.d.ts +55 -0
- package/dist/Helpers/NexusDriveAdapter.js +207 -0
- package/dist/Helpers/NexusHelpers.d.ts +0 -0
- package/dist/Helpers/NexusHelpers.js +1 -0
- package/dist/Helpers/NumberHelpers.d.ts +95 -0
- package/dist/Helpers/NumberHelpers.js +126 -0
- package/dist/Helpers/Permissions.js +2 -1
- package/dist/Helpers/QrCode.d.ts +26 -0
- package/dist/Helpers/QrCode.js +463 -0
- package/dist/Helpers/RecordCards.d.ts +205 -0
- package/dist/Helpers/RecordCards.js +378 -0
- package/dist/Helpers/RequestCache.d.ts +47 -0
- package/dist/Helpers/RequestCache.js +132 -0
- package/dist/Helpers/RowsStore.d.ts +37 -2
- package/dist/Helpers/RowsStore.js +128 -10
- package/dist/Helpers/ServerPreview.d.ts +69 -0
- package/dist/Helpers/ServerPreview.js +99 -0
- package/dist/Helpers/TableColumns.js +4 -3
- package/dist/Helpers/TableExport.d.ts +7 -1
- package/dist/Helpers/TableExport.js +21 -15
- package/dist/Helpers/TableFilters.d.ts +1 -1
- package/dist/Helpers/TableFilters.js +24 -23
- package/dist/Helpers/TableHelpers.d.ts +24 -3
- package/dist/Helpers/TableHelpers.js +58 -12
- package/dist/Helpers/TableJoins.d.ts +83 -0
- package/dist/Helpers/TableJoins.js +284 -0
- package/dist/Helpers/TableStore.d.ts +26 -1
- package/dist/Helpers/TableStore.js +73 -7
- package/dist/Helpers/TreeStore.d.ts +306 -11
- package/dist/Helpers/TreeStore.js +1048 -90
- package/dist/Helpers/UserPreferences.d.ts +8 -0
- package/dist/Helpers/UserPreferences.js +17 -0
- package/dist/Interfaces/CrudInterfaces.d.ts +512 -14
- package/dist/Interfaces/FileInterfaces.d.ts +162 -0
- package/dist/Interfaces/FileInterfaces.js +4 -0
- package/dist/Interfaces/FormInterfaces.d.ts +393 -29
- package/dist/Interfaces/FormInterfaces.js +79 -1
- package/dist/Interfaces/InputInterfaces.d.ts +39 -3
- 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/RecordInterfaces.d.ts +367 -0
- package/dist/Interfaces/RecordInterfaces.js +1 -0
- package/dist/Interfaces/TableInterfaces.d.ts +160 -5
- 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/ExcelApi.js +5 -0
- package/dist/Services/FileApi.d.ts +13 -0
- package/dist/Services/FileApi.js +29 -0
- package/dist/Services/ImageEditorLoader.d.ts +14 -0
- package/dist/Services/ImageEditorLoader.js +51 -0
- package/dist/Services/JoinSources.d.ts +80 -0
- package/dist/Services/JoinSources.js +150 -0
- package/dist/Services/ModuleImport.d.ts +7 -0
- package/dist/Services/ModuleImport.js +22 -0
- package/dist/Services/ServerApi.d.ts +13 -2
- package/dist/Services/ServerApi.js +8 -3
- package/dist/Shared.Index.d.ts +20 -0
- package/dist/Shared.Index.js +20 -0
- package/package.json +92 -85
- package/src/Styles/Nexus.Avatar.css +80 -79
- package/src/Styles/Nexus.Base.css +117 -117
- package/src/Styles/Nexus.Button.css +186 -180
- package/src/Styles/Nexus.Calendar.css +273 -273
- package/src/Styles/Nexus.Chat.css +1443 -1443
- package/src/Styles/Nexus.Choice.css +359 -343
- package/src/Styles/Nexus.Crud.css +542 -168
- package/src/Styles/Nexus.DateTime.css +81 -81
- package/src/Styles/Nexus.Dropdown.css +733 -725
- package/src/Styles/Nexus.Editor.css +295 -295
- package/src/Styles/Nexus.Excel.css +389 -389
- package/src/Styles/Nexus.Files.css +410 -0
- package/src/Styles/Nexus.Form.css +282 -279
- package/src/Styles/Nexus.Index.css +41 -37
- package/src/Styles/Nexus.Input.css +238 -4
- package/src/Styles/Nexus.Markdown.css +237 -237
- package/src/Styles/Nexus.Menu.css +246 -237
- package/src/Styles/Nexus.Popup.css +800 -724
- package/src/Styles/Nexus.Print.css +288 -0
- package/src/Styles/Nexus.QrCode.css +12 -0
- package/src/Styles/Nexus.Record.css +430 -0
- package/src/Styles/Nexus.Rows.css +637 -637
- package/src/Styles/Nexus.Slider.css +276 -276
- package/src/Styles/Nexus.Tab.Buttons.css +497 -432
- package/src/Styles/Nexus.Table.css +98 -35
- package/src/Styles/Nexus.Theme.Picker.css +59 -3
- package/src/Styles/Nexus.Themes.css +200 -194
- package/src/Styles/Nexus.Time.css +144 -144
- package/src/Styles/Nexus.Toast.css +263 -263
- package/src/Styles/Nexus.Tokens.css +94 -89
- package/src/Styles/Nexus.Tree.css +530 -431
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { IconNode } from "nexus-icons";
|
|
1
2
|
import type { CSSProperties, ReactNode, Ref } from "react";
|
|
2
3
|
import type { TableStore } from "../Helpers/TableStore.ts";
|
|
4
|
+
import type { ApiEndpoint } from "./ApiInterfaces.ts";
|
|
3
5
|
import type { ExcelEndpoints } from "./ExcelInterfaces.ts";
|
|
4
6
|
import type { InputParams } from "./FormInterfaces.ts";
|
|
5
7
|
import type { Size, Tone } from "./TypeInterfaces.ts";
|
|
@@ -16,6 +18,11 @@ export interface TableColumnOption {
|
|
|
16
18
|
/** Draws the value as a tag with a dot of this color. */
|
|
17
19
|
tone?: Tone;
|
|
18
20
|
}
|
|
21
|
+
/** A short tag after a cell's value (`TableColumn.tag`): its words and the color of its dot. */
|
|
22
|
+
export interface TableTag {
|
|
23
|
+
label: string;
|
|
24
|
+
tone?: Tone;
|
|
25
|
+
}
|
|
19
26
|
/** One column: which field it shows, how, and what the user can do with it. */
|
|
20
27
|
export interface TableColumn<TRow extends TableRow = TableRow> {
|
|
21
28
|
/** The field: `"name"`, or a path into the row, `"guest.name"`. It is also the column's id and its sort name. */
|
|
@@ -37,6 +44,11 @@ export interface TableColumn<TRow extends TableRow = TableRow> {
|
|
|
37
44
|
decimals?: number;
|
|
38
45
|
/** Text before a number, such as "Rs. ". */
|
|
39
46
|
prefix?: string;
|
|
47
|
+
/**
|
|
48
|
+
* A `money` column's currency, written before the figures with a space: "Rs." gives "Rs. 1,250.00". A text, or the
|
|
49
|
+
* row's own (`row => row.currencyText`). Default the table's `currency`, so a page sets its company's once.
|
|
50
|
+
*/
|
|
51
|
+
currency?: string | ((row: TRow) => string | null | undefined);
|
|
40
52
|
/** Text after a number, such as " kg". */
|
|
41
53
|
suffix?: string;
|
|
42
54
|
/** Labels for the values the column holds, such as the names of an enum's numbers. */
|
|
@@ -65,6 +77,8 @@ export interface TableColumn<TRow extends TableRow = TableRow> {
|
|
|
65
77
|
hideable?: boolean;
|
|
66
78
|
/** Long text wraps onto more lines instead of ending in "…". */
|
|
67
79
|
wrap?: boolean;
|
|
80
|
+
/** Its text is Markdown: the table shows the text, and a record's details show it formatted. */
|
|
81
|
+
markdown?: boolean;
|
|
68
82
|
/** The user can drag the heading to another place, or move it from its menu. Default true. */
|
|
69
83
|
movable?: boolean;
|
|
70
84
|
/** The user can drag the edge of the heading to change the width. Default true; `false` for a column that must keep its width. */
|
|
@@ -73,6 +87,61 @@ export interface TableColumn<TRow extends TableRow = TableRow> {
|
|
|
73
87
|
pinnable?: boolean;
|
|
74
88
|
/** Pinned before the user pins anything: `left` keeps it at the start, `right` at the end. */
|
|
75
89
|
pinned?: TablePinSide;
|
|
90
|
+
/**
|
|
91
|
+
* The value is another record's, looked up by the key this row holds: a country's sub-region named from the
|
|
92
|
+
* sub-regions' `FindListing`. The table collects every key its rows hold, asks once for the ones it has not got, keeps
|
|
93
|
+
* the answers for every table on the page, and shows the field once they are in - searched and sorted like any other
|
|
94
|
+
* column. A key the source does not answer shows as "Not found". (A join the database already made needs none of
|
|
95
|
+
* this: the row carries the name, and `tag` reads its status.)
|
|
96
|
+
*/
|
|
97
|
+
join?: TableJoin;
|
|
98
|
+
/**
|
|
99
|
+
* A short tag after the value: "Trashed" beside a parent that is in the trash. Given the row and, on a `join` column,
|
|
100
|
+
* the record it looked up; nothing when it answers nothing. `crudColumns.dbJoin` and `crudColumns.clientJoin` set it.
|
|
101
|
+
*/
|
|
102
|
+
tag?: (row: TRow, joined?: TableJoinedRecord) => TableTag | null | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* The value is another record's name the database joined into the row, by the key this row holds in `from`
|
|
105
|
+
* (`crudColumns.dbJoin`). The table shows the row as it is; a CRUD that saved the row writes the name - and the status
|
|
106
|
+
* in `status` - again from the record picked for that key, with no call (`crudJoinedNames`). Without `from`, or where
|
|
107
|
+
* no record is known for the key, it reads the saved record back once (`details`) instead (`crudJoinsMissing`).
|
|
108
|
+
*/
|
|
109
|
+
dbJoin?: TableDbJoin;
|
|
110
|
+
}
|
|
111
|
+
/** Where a column the database joined takes its name from (`TableColumn.dbJoin`). */
|
|
112
|
+
export interface TableDbJoin {
|
|
113
|
+
/**
|
|
114
|
+
* The field of this row that holds the other record's key, and the form's input for it: `"parentId"`. Without it a
|
|
115
|
+
* save cannot tell whose name the column shows, and a new record is read back once for it.
|
|
116
|
+
*/
|
|
117
|
+
from?: string;
|
|
118
|
+
/** The field of this row that holds the other record's status: `"parentRecordStatus"`. */
|
|
119
|
+
status?: string;
|
|
120
|
+
}
|
|
121
|
+
/** A record a `join` looked up: whatever its source answers for one key. */
|
|
122
|
+
export type TableJoinedRecord = Readonly<Record<string, unknown>>;
|
|
123
|
+
/** Where a `join` looks records up: the records behind a set of keys, in any status. */
|
|
124
|
+
export interface TableJoinSource {
|
|
125
|
+
/** One name per source. Every column and table that names it shares its answers and its calls. */
|
|
126
|
+
readonly name: string;
|
|
127
|
+
/** The records behind these keys; a key with no record is left out. One call for every key the page needs at once. */
|
|
128
|
+
load(keys: readonly string[], signal: AbortSignal): Promise<readonly TableJoinedRecord[]>;
|
|
129
|
+
/** The field of a loaded record that holds its key. Default `id`. */
|
|
130
|
+
readonly key?: string;
|
|
131
|
+
/** How long an answer is kept, in milliseconds, before it is asked again. Default 5 minutes. */
|
|
132
|
+
readonly lifetime?: number;
|
|
133
|
+
}
|
|
134
|
+
/** A column whose value is another record's (`TableColumn.join`). */
|
|
135
|
+
export interface TableJoin {
|
|
136
|
+
/** The field of this row that holds the other record's key: `"subRegionId"`. */
|
|
137
|
+
from: string;
|
|
138
|
+
/** The field of the other record the column shows. Default: the column's own `key`. */
|
|
139
|
+
field?: string;
|
|
140
|
+
/**
|
|
141
|
+
* Where the records come from: an endpoint the keys are POSTed to as an array (a Nexus controller's `findListing`),
|
|
142
|
+
* or a source of the page's own.
|
|
143
|
+
*/
|
|
144
|
+
source: ApiEndpoint | TableJoinSource;
|
|
76
145
|
}
|
|
77
146
|
/** Which side a pinned column stays on while the others scroll. */
|
|
78
147
|
export type TablePinSide = "left" | "right";
|
|
@@ -117,6 +186,8 @@ export interface TablePage<TRow extends TableRow = TableRow> {
|
|
|
117
186
|
export type TableLoader<TRow extends TableRow = TableRow> = (query: TableQuery, signal: AbortSignal) => TablePage<TRow> | readonly TRow[] | Promise<TablePage<TRow> | readonly TRow[]>;
|
|
118
187
|
/** `client`: the table has every row and pages them itself. `server`: each page comes from the loader. */
|
|
119
188
|
export type TablePaging = "client" | "server";
|
|
189
|
+
/** Where a table keeps its paging (`DataTableProps.pager`): the rows per page in the bar or in the footer, or every row on one page. */
|
|
190
|
+
export type TablePagerMode = "bar" | "footer" | "none";
|
|
120
191
|
/** `loading`: the first rows are on their way. `ready`: rows (or none) are shown. `error`: the rows could not load. */
|
|
121
192
|
export type TableStatus = "loading" | "ready" | "error";
|
|
122
193
|
/** What the table shows now. A new object after every change. */
|
|
@@ -137,6 +208,8 @@ export interface TableState<TRow extends TableRow = TableRow> {
|
|
|
137
208
|
query: TableQuery;
|
|
138
209
|
/** The selected rows by key (keys as text), in the order they were picked. */
|
|
139
210
|
selected: ReadonlyMap<string, TRow>;
|
|
211
|
+
/** The one row the user picked (`DataTable` `onPick`), as the table has it now: its newest copy. `null` for none. */
|
|
212
|
+
picked: TRow | null;
|
|
140
213
|
/** Rows with an action running. */
|
|
141
214
|
busy: ReadonlySet<string>;
|
|
142
215
|
/** Rows just added or changed, highlighted for a moment. */
|
|
@@ -155,6 +228,12 @@ export interface TableViewColumn<TRow extends TableRow = TableRow> {
|
|
|
155
228
|
column: TableColumn<TRow>;
|
|
156
229
|
/** The side it stays on, or null when it scrolls with the rest. */
|
|
157
230
|
pin: TablePinSide | null;
|
|
231
|
+
/**
|
|
232
|
+
* The side it is pinned to (the person's choice or the column's): where it is drawn, among which columns it moves,
|
|
233
|
+
* and what its menu offers. `pin` is the same unless the table is too narrow for it to stay (`PINNED_SHARE`): then
|
|
234
|
+
* `pin` is null and it scrolls with the rest until there is room again.
|
|
235
|
+
*/
|
|
236
|
+
side: TablePinSide | null;
|
|
158
237
|
/** Pixels from that edge, once the table has measured the columns before it. */
|
|
159
238
|
offset: number;
|
|
160
239
|
/** The last pinned column of its side: the one that shows the shadow while columns scroll under it. */
|
|
@@ -212,6 +291,30 @@ export interface TableMenuAction {
|
|
|
212
291
|
separator?: boolean;
|
|
213
292
|
onSelect: () => void;
|
|
214
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Rows kept in a section above the page, which the user folds open and shut: the records they pinned, their
|
|
296
|
+
* favourites. They are the rows as they came from their own endpoint, the same records also show in the list below,
|
|
297
|
+
* and each section's row wears the section's icon where the rows of the page show their number.
|
|
298
|
+
*/
|
|
299
|
+
export interface TableRowSection<TRow extends TableRow = TableRow> {
|
|
300
|
+
/** Tells the sections apart; the table remembers each one's fold under it. */
|
|
301
|
+
key: string;
|
|
302
|
+
/** What the section is called: "Pinned rooms". */
|
|
303
|
+
label: string;
|
|
304
|
+
/** The section's icon, in its head and on each of its rows. */
|
|
305
|
+
icon: IconNode;
|
|
306
|
+
rows: readonly TRow[];
|
|
307
|
+
/** The rows are on their way: the section shows that it is loading. */
|
|
308
|
+
loading?: boolean;
|
|
309
|
+
/** Most rows the section can hold, shown beside the count so the user knows the limit. */
|
|
310
|
+
max?: number;
|
|
311
|
+
}
|
|
312
|
+
/** A mark a row carries for this user - pinned, a favourite - drawn in its first column where its number would be. */
|
|
313
|
+
export interface TableRowMark {
|
|
314
|
+
icon: IconNode;
|
|
315
|
+
/** What the mark says: "Pinned". */
|
|
316
|
+
label: string;
|
|
317
|
+
}
|
|
215
318
|
/** A way to select rows by what they are, such as "Pinned" or "Not flagged". */
|
|
216
319
|
export interface TableSelectBy<TRow extends TableRow = TableRow> {
|
|
217
320
|
label: string;
|
|
@@ -270,6 +373,11 @@ export interface DataTableProps<TRow extends TableRow = TableRow> {
|
|
|
270
373
|
searchable?: boolean;
|
|
271
374
|
/** Default "Search…". */
|
|
272
375
|
searchPlaceholder?: string;
|
|
376
|
+
/**
|
|
377
|
+
* A line at the top of the table, inside its frame and over its bar, saying what its rows are: whose records a narrowed
|
|
378
|
+
* `Crud` shows ("Places of: Ram Thapa"). Beside another table, the two frames still start level.
|
|
379
|
+
*/
|
|
380
|
+
caption?: ReactNode;
|
|
273
381
|
/** On the left of the bar, after the Add section: tabs, filters. On a narrow table the line under the search. */
|
|
274
382
|
toolbar?: ReactNode;
|
|
275
383
|
/** The page's own buttons, in the Add section after the table's Add: first in the bar, last on a phone. */
|
|
@@ -277,10 +385,19 @@ export interface DataTableProps<TRow extends TableRow = TableRow> {
|
|
|
277
385
|
/**
|
|
278
386
|
* The table's Add, first in the bar. With `excel`, its arrow holds the template, the import and the export, which
|
|
279
387
|
* then leave the options menu.
|
|
388
|
+
*
|
|
389
|
+
* Its `description` is its tooltip and what a screen reader hears after its name. `disabled` with a `description`
|
|
390
|
+
* says why nothing can be added yet: the button stays in the Tab order, looks unavailable, and a press - a click, a
|
|
391
|
+
* tap, Enter - shows the description in a note under it, as a `TreeView`'s own button does; the spreadsheet items then
|
|
392
|
+
* wait in the options menu, without Bulk insert. `onDisabledPress` answers such a press first: when it returns true the
|
|
393
|
+
* page has answered it itself (a CRUD narrowed by a box of its own moves the cursor to that box), and no note shows.
|
|
280
394
|
*/
|
|
281
395
|
add?: {
|
|
282
396
|
label: ReactNode;
|
|
283
397
|
onClick: () => void;
|
|
398
|
+
disabled?: boolean;
|
|
399
|
+
description?: string;
|
|
400
|
+
onDisabledPress?: () => boolean;
|
|
284
401
|
};
|
|
285
402
|
/** The Columns panel, where columns are shown, hidden, and dragged into another order. Default true. */
|
|
286
403
|
columnsMenu?: boolean;
|
|
@@ -340,8 +457,32 @@ export interface DataTableProps<TRow extends TableRow = TableRow> {
|
|
|
340
457
|
*/
|
|
341
458
|
openOnClick?: boolean;
|
|
342
459
|
/**
|
|
343
|
-
* Rows
|
|
344
|
-
*
|
|
460
|
+
* Rows the user picks one of, for a page that shows what belongs to the picked one beside the table: a person's
|
|
461
|
+
* places beside the people. A click on a row picks it (a double-click still opens it, and `openOnClick` gives way); the
|
|
462
|
+
* keyboard reaches the rows through a radio button each, read with the row's name: Tab to the picked one, the arrow
|
|
463
|
+
* keys to move the pick. The picked row is tinted as a selected one is, and the rows are numbered unless `numbered` is
|
|
464
|
+
* false.
|
|
465
|
+
*
|
|
466
|
+
* The pick lives in the table's store (`TableState.picked`, `TableStore.pick`) and follows the record: a search, a
|
|
467
|
+
* sort or another page keep it; an edit hands the page its new copy; it is let go when the record leaves the rows
|
|
468
|
+
* (trashed, deleted, gone after a reload) and when another list starts over (`loadKey`). Called with every change,
|
|
469
|
+
* `null` included.
|
|
470
|
+
*/
|
|
471
|
+
onPick?: (row: TRow | null) => void;
|
|
472
|
+
/**
|
|
473
|
+
* Sections above the page, in this order, each folding open and shut on its own: the records the user pinned, then
|
|
474
|
+
* their favourites. A section with no rows and none on their way does not show.
|
|
475
|
+
*/
|
|
476
|
+
sections?: readonly TableRowSection<TRow>[];
|
|
477
|
+
/**
|
|
478
|
+
* The marks each row carries (pinned, a favourite), in its first column in place of its number, one under the
|
|
479
|
+
* other - in the list and in the sections alike, so no column of their own is needed. A row with none shows its
|
|
480
|
+
* number; a section's row with none shows the section's icon.
|
|
481
|
+
*/
|
|
482
|
+
rowMarks?: (row: TRow) => readonly TableRowMark[];
|
|
483
|
+
/**
|
|
484
|
+
* One section of pinned rows, before any `sections`: the same as a section with the key `pins` and the pin icon.
|
|
485
|
+
* Leave it out for no such section.
|
|
345
486
|
*/
|
|
346
487
|
pinnedRows?: readonly TRow[];
|
|
347
488
|
/** The pinned rows are on their way: the section shows that it is loading. */
|
|
@@ -350,6 +491,14 @@ export interface DataTableProps<TRow extends TableRow = TableRow> {
|
|
|
350
491
|
pinnedLabel?: string;
|
|
351
492
|
/** Most rows that can be pinned, shown beside the count so the user knows the limit. */
|
|
352
493
|
pinnedMax?: number;
|
|
494
|
+
/**
|
|
495
|
+
* How the rows are paged on screen (`TablePagerMode`). `"bar"` (default): rows per page inside the search box, or in the
|
|
496
|
+
* bar without a search, and the pager in the footer. `"footer"`: rows per page beside the pager, so a table with nothing
|
|
497
|
+
* else above its rows draws no bar. `"none"`: every row on one page, for a short list read whole (a record's notes, at
|
|
498
|
+
* most 50): no pager, no rows per page, and no footer unless a search or the filters leave rows out (a screen reader
|
|
499
|
+
* still hears the count). A server's pages cannot all be shown: with `paging="server"`, `"none"` is `"footer"`.
|
|
500
|
+
*/
|
|
501
|
+
pager?: TablePagerMode;
|
|
353
502
|
/** Rows per page. Default 10, or the user's last choice with `stateKey`. */
|
|
354
503
|
pageSize?: number;
|
|
355
504
|
/** Choices of rows per page. Default 10, 25, 50, 100. */
|
|
@@ -358,12 +507,18 @@ export interface DataTableProps<TRow extends TableRow = TableRow> {
|
|
|
358
507
|
defaultSort?: TableSort | null;
|
|
359
508
|
/**
|
|
360
509
|
* Row numbers in the first column. Default: on when `selectable`, where the number becomes the row's checkbox as soon
|
|
361
|
-
* as the pointer is over the row or anything is selected
|
|
510
|
+
* as the pointer is over the row or anything is selected, and when rows are picked (`onPick`), where the first column
|
|
511
|
+
* also holds each row's radio button.
|
|
362
512
|
*/
|
|
363
513
|
numbered?: boolean;
|
|
364
|
-
/** Remembers the page size, the sort, hidden columns, the order, widths, and pins of the columns, and
|
|
365
|
-
*
|
|
514
|
+
/** Remembers the page size, the sort, hidden columns, the order, widths, and pins of the columns, and which sections
|
|
515
|
+
* are open, on this device. */
|
|
366
516
|
stateKey?: string;
|
|
517
|
+
/**
|
|
518
|
+
* The currency of every `money` column without its own (`TableColumn.currency`): "Rs." writes "Rs. 1,250.00" in the
|
|
519
|
+
* cells, the details, the CSV and the print. Set once per table from the company's settings.
|
|
520
|
+
*/
|
|
521
|
+
currency?: string | null;
|
|
367
522
|
/** Milliseconds after the last key before a search goes to a server. Default 300; a table with every row searches at once. */
|
|
368
523
|
searchDelay?: number;
|
|
369
524
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ApiModuleValue } from "../Interfaces/ApiInterfaces.ts";
|
|
2
|
+
import { type ApiProxyCode, type ApiProxyFailure } from "./ApiProxyRules.ts";
|
|
3
|
+
export { DEFAULT_PROXY_BODY_LIMIT, DEFAULT_PROXY_REDIRECTS, DEFAULT_PROXY_RESPONSE_HEADERS, lastForwardedAddress, nexusFailure, PROXY_REFUSALS, readIpAddress, type ApiProxyCode, type ApiProxyFailure } from "./ApiProxyRules.ts";
|
|
2
4
|
export interface ApiProxyOptions {
|
|
3
5
|
/** The user's token for the backend, such as from the session cookie. Without one, the request's own Authorization header goes on. */
|
|
4
6
|
token?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
@@ -6,10 +8,34 @@ export interface ApiProxyOptions {
|
|
|
6
8
|
headers?: (request: Request) => Record<string, string> | undefined | Promise<Record<string, string> | undefined>;
|
|
7
9
|
/** Serves only these modules, of those the app's `ApiModules` sends through the proxy. Default: all of those. */
|
|
8
10
|
allow?: readonly ApiModuleValue[];
|
|
9
|
-
/** Milliseconds to wait for the backend to start answering. Default 60,000. */
|
|
11
|
+
/** Milliseconds to wait for the backend to start answering, its redirects included. Default 60,000. */
|
|
10
12
|
timeout?: number;
|
|
11
13
|
/** The `fetch` to use. Default: the global one. */
|
|
12
14
|
fetch?: typeof fetch;
|
|
15
|
+
/**
|
|
16
|
+
* More of the backend's response headers to pass on, besides `DEFAULT_PROXY_RESPONSE_HEADERS` and the Nexus backend's
|
|
17
|
+
* own `X-Nexus-*`: `["X-Total-Count"]`. A header that names the backend or carries its own policies (Server,
|
|
18
|
+
* X-Powered-By, X-AspNet-*, Via, Alt-Svc, Strict-Transport-Security, CORS, Location, Link) is dropped even when named.
|
|
19
|
+
*/
|
|
20
|
+
responseHeaders?: readonly string[];
|
|
21
|
+
/** Passes the backend's Set-Cookie headers to the browser. Default false: the app keeps its own session. */
|
|
22
|
+
passCookies?: boolean;
|
|
23
|
+
/** The most bytes a request body may have; a bigger one is refused with 413 before it reaches the backend. Default 100 MB. */
|
|
24
|
+
maxBodySize?: number;
|
|
25
|
+
/** How many redirects inside a module to follow for one call; 0 refuses every redirect. Default 3. */
|
|
26
|
+
maxRedirects?: number;
|
|
27
|
+
/**
|
|
28
|
+
* The client's IP address, sent to the backend as X-Forwarded-For for its sign-in lockout and rate limits. Default:
|
|
29
|
+
* the last address of the X-Forwarded-For the app received, the one Next.js or the app's own reverse proxy wrote. Pass
|
|
30
|
+
* it behind more than one proxy of the app's own, or behind a CDN that names the client in a header of its own.
|
|
31
|
+
*/
|
|
32
|
+
clientAddress?: (request: Request) => string | null | undefined | Promise<string | null | undefined>;
|
|
33
|
+
/** Lets pages of other sites call through the proxy. Default false: the app's own pages, links followed, and non-browser callers. */
|
|
34
|
+
allowCrossSite?: boolean;
|
|
35
|
+
/** Builds the answer to a refusal of the proxy's own, for a backend whose envelope is not Nexus's. Default `nexusFailure`. */
|
|
36
|
+
failure?: (failure: ApiProxyFailure) => Response;
|
|
37
|
+
/** Hears what the proxy hid or refused, on the server. Default: one line in `console.error`. */
|
|
38
|
+
onProblem?: (problem: ApiProxyProblem) => void;
|
|
13
39
|
}
|
|
14
40
|
/** What a Next.js route handler for `[module]/[...path]` receives. */
|
|
15
41
|
export interface ApiProxyContext {
|
|
@@ -18,6 +44,15 @@ export interface ApiProxyContext {
|
|
|
18
44
|
path?: string[];
|
|
19
45
|
}>;
|
|
20
46
|
}
|
|
47
|
+
/** What the proxy hid or refused, for the server's log. It never holds the user's token or a query string. */
|
|
48
|
+
export interface ApiProxyProblem {
|
|
49
|
+
/** The refusal's code, the one the browser got: "BACKEND_REDIRECT_REFUSED". */
|
|
50
|
+
messageCode: ApiProxyCode;
|
|
51
|
+
/** The call, as the browser made it: "POST configuration/Region/MassRecover". */
|
|
52
|
+
call: string;
|
|
53
|
+
/** What happened, for a developer: the backend's address and status, a piece of its error page, why it failed. */
|
|
54
|
+
detail: string;
|
|
55
|
+
}
|
|
21
56
|
/**
|
|
22
57
|
* Makes the route handlers of the proxy. Only the modules' configured roots can be reached: a path cannot climb out of
|
|
23
58
|
* its module (`..` is refused), and the query goes on unchanged.
|
|
@@ -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
|
}
|