@xh/hoist 77.0.0-SNAPSHOT.1761157373322 → 77.0.0-SNAPSHOT.1761229417098
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 +13 -0
- package/admin/tabs/cluster/instances/logs/levels/LogLevelDialogModel.ts +1 -1
- package/admin/tabs/general/config/ConfigPanelModel.ts +1 -1
- package/admin/tabs/monitor/editor/MonitorEditorDialog.ts +1 -1
- package/admin/tabs/userData/jsonblob/JsonBlobModel.ts +1 -1
- package/admin/tabs/userData/prefs/UserPreferenceModel.ts +1 -1
- package/admin/tabs/userData/prefs/editor/PrefEditorModel.ts +1 -1
- package/build/types/core/AppSpec.d.ts +14 -7
- package/build/types/data/Field.d.ts +18 -9
- package/build/types/data/Store.d.ts +1 -1
- package/build/types/data/cube/Query.d.ts +1 -1
- package/core/AppSpec.ts +14 -7
- package/data/Field.ts +24 -20
- package/data/Store.ts +8 -5
- package/data/cube/Query.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## 77.0.0-SNAPSHOT - unreleased
|
|
4
4
|
|
|
5
|
+
### 💥 Breaking Changes
|
|
6
|
+
|
|
7
|
+
* The `disableXssProtection` flag supported by `AppSpec` and `FieldSpec` has been removed and
|
|
8
|
+
replaced with its opposite, `enableXssProtection`, now an opt-in feature.
|
|
9
|
+
* While store-based XSS protection via DomPurify is still available to apps that can display
|
|
10
|
+
untrusted or potentially malicious data, this is an uncommon use case for Hoist apps and was
|
|
11
|
+
deemed to not provide enough benefit relative to potential performance pitfalls for most
|
|
12
|
+
applications. In addition, the core change to React-based AG Grid rendering has reduced the
|
|
13
|
+
attack surface for such exploits relative to when this system was first implemented.
|
|
14
|
+
* Apps that were previously opting-out via `disableXssProtection` should simply remove that
|
|
15
|
+
flag. Apps for which this protection remains important should enable at either the app level
|
|
16
|
+
or for selected Fields and/or Stores.
|
|
17
|
+
|
|
5
18
|
## 76.2.0 - 2025-10-22
|
|
6
19
|
|
|
7
20
|
### ⚙️ Technical
|
|
@@ -57,7 +57,7 @@ export class ConfigPanelModel extends HoistModel {
|
|
|
57
57
|
store: new RestStore({
|
|
58
58
|
url: 'rest/configAdmin',
|
|
59
59
|
reloadLookupsOnLoad: true,
|
|
60
|
-
fieldDefaults: {
|
|
60
|
+
fieldDefaults: {enableXssProtection: false},
|
|
61
61
|
fields: [
|
|
62
62
|
{...(Col.name.field as FieldSpec), required},
|
|
63
63
|
{
|
|
@@ -51,7 +51,7 @@ const modelSpec: RestGridConfig = {
|
|
|
51
51
|
showRefreshButton: true,
|
|
52
52
|
store: {
|
|
53
53
|
url: 'rest/monitorAdmin',
|
|
54
|
-
fieldDefaults: {
|
|
54
|
+
fieldDefaults: {enableXssProtection: false},
|
|
55
55
|
fields: [
|
|
56
56
|
{...(MCol.code.field as FieldSpec), required},
|
|
57
57
|
MCol.metricUnit.field,
|
|
@@ -51,7 +51,7 @@ export class JsonBlobModel extends HoistModel {
|
|
|
51
51
|
store: {
|
|
52
52
|
url: 'rest/jsonBlobAdmin',
|
|
53
53
|
reloadLookupsOnLoad: true,
|
|
54
|
-
fieldDefaults: {
|
|
54
|
+
fieldDefaults: {enableXssProtection: false},
|
|
55
55
|
fields: [
|
|
56
56
|
{...(JBCol.token.field as FieldSpec), editable: false},
|
|
57
57
|
JBCol.owner.field,
|
|
@@ -34,7 +34,7 @@ export class UserPreferenceModel extends HoistModel {
|
|
|
34
34
|
store: {
|
|
35
35
|
url: 'rest/userPreferenceAdmin',
|
|
36
36
|
reloadLookupsOnLoad: true,
|
|
37
|
-
fieldDefaults: {
|
|
37
|
+
fieldDefaults: {enableXssProtection: false},
|
|
38
38
|
fields: [
|
|
39
39
|
{
|
|
40
40
|
...(Col.name.field as FieldSpec),
|
|
@@ -47,7 +47,7 @@ export class PrefEditorModel extends HoistModel {
|
|
|
47
47
|
store: {
|
|
48
48
|
url: 'rest/preferenceAdmin',
|
|
49
49
|
reloadLookupsOnLoad: true,
|
|
50
|
-
fieldDefaults: {
|
|
50
|
+
fieldDefaults: {enableXssProtection: false},
|
|
51
51
|
fields: [
|
|
52
52
|
{...(Col.name.field as FieldSpec), required},
|
|
53
53
|
{
|
|
@@ -58,12 +58,19 @@ export declare class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
58
58
|
*/
|
|
59
59
|
disableWebSockets?: boolean;
|
|
60
60
|
/**
|
|
61
|
-
* True to
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* True to enable Field-level XSS protection by default across all Stores/Fields in the app.
|
|
62
|
+
* Available as an extra precaution for use with apps that might display arbitrary input from
|
|
63
|
+
* untrusted or external users. This feature does exact a minor performance penalty during data
|
|
64
|
+
* parsing, which can be significant in aggregate for very large stores containing records with
|
|
65
|
+
* many `string` fields.
|
|
66
|
+
*
|
|
67
|
+
* Note: this flag and its default behavior was changed as of Hoist v77 to be `false`, i.e.
|
|
68
|
+
* Store-level XSS protection *disabled* by default, in keeping with Hoist's primary use-case:
|
|
69
|
+
* building secured internal apps with large datasets and tight performance tolerances.
|
|
70
|
+
*
|
|
71
|
+
* @see FieldSpec.enableXssProtection
|
|
65
72
|
*/
|
|
66
|
-
|
|
73
|
+
enableXssProtection?: boolean;
|
|
67
74
|
/**
|
|
68
75
|
* True to show a login form on initialization when not authenticated. Default is `false` as
|
|
69
76
|
* most Hoist applications are expected to use OAuth or SSO for authn.
|
|
@@ -111,7 +118,7 @@ export declare class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
111
118
|
trackAppLoad?: boolean;
|
|
112
119
|
/** @deprecated - use {@link AppSpec.disableWebSockets} instead. */
|
|
113
120
|
webSocketsEnabled?: boolean;
|
|
114
|
-
constructor({ authModelClass, checkAccess, clientAppCode, clientAppName, componentClass, containerClass, disableWebSockets,
|
|
121
|
+
constructor({ authModelClass, checkAccess, clientAppCode, clientAppName, componentClass, containerClass, disableWebSockets, enableXssProtection, enableLoginForm, enableLogout, idlePanel, isMobileApp, lockoutMessage, lockoutPanel, loginMessage, modelClass, showBrowserContextMenu, trackAppLoad, webSocketsEnabled }: {
|
|
115
122
|
authModelClass?: typeof HoistAuthModel;
|
|
116
123
|
checkAccess: any;
|
|
117
124
|
clientAppCode?: string;
|
|
@@ -119,7 +126,7 @@ export declare class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
119
126
|
componentClass: any;
|
|
120
127
|
containerClass: any;
|
|
121
128
|
disableWebSockets?: boolean;
|
|
122
|
-
|
|
129
|
+
enableXssProtection?: boolean;
|
|
123
130
|
enableLoginForm?: boolean;
|
|
124
131
|
enableLogout?: boolean;
|
|
125
132
|
idlePanel?: any;
|
|
@@ -17,15 +17,24 @@ export interface FieldSpec {
|
|
|
17
17
|
/** Rules to apply to this field. */
|
|
18
18
|
rules?: RuleLike[];
|
|
19
19
|
/**
|
|
20
|
-
* True to
|
|
21
|
-
*
|
|
20
|
+
* True to enable built-in XSS (cross-site scripting) protection to all incoming String values
|
|
21
|
+
* using {@link https://github.com/cure53/DOMPurify | DOMPurify}.
|
|
22
22
|
*
|
|
23
23
|
* DOMPurify provides fast escaping of dangerous HTML, scripting, and other content that can be
|
|
24
24
|
* used to execute XSS attacks, while allowing common and expected HTML and style tags.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
26
|
+
* This feature does exact a minor performance penalty during data parsing, which can be
|
|
27
|
+
* significant in aggregate for very large stores containing records with many `string` fields.
|
|
28
|
+
*
|
|
29
|
+
* For extra safety, apps which are open to potentially-untrusted users or display other
|
|
30
|
+
* potentially dangerous string content can opt into this setting app-wide via
|
|
31
|
+
* {@link AppSpec.enableXssProtection}. Field-level setting will override any app-level default.
|
|
32
|
+
*
|
|
33
|
+
* Note: this flag and its default behavior was changed as of Hoist v77 to be `false`, i.e.
|
|
34
|
+
* Store-level XSS protection *disabled* by default, in keeping with Hoist's primary use-case:
|
|
35
|
+
* building secured internal apps with large datasets and tight performance tolerances.
|
|
27
36
|
*/
|
|
28
|
-
|
|
37
|
+
enableXssProtection?: boolean;
|
|
29
38
|
}
|
|
30
39
|
/** Metadata for an individual data field within a {@link StoreRecord}. */
|
|
31
40
|
export declare class Field {
|
|
@@ -35,8 +44,8 @@ export declare class Field {
|
|
|
35
44
|
readonly displayName: string;
|
|
36
45
|
readonly defaultValue: any;
|
|
37
46
|
readonly rules: Rule[];
|
|
38
|
-
readonly
|
|
39
|
-
constructor({ name, type, displayName, defaultValue, rules,
|
|
47
|
+
readonly enableXssProtection: boolean;
|
|
48
|
+
constructor({ name, type, displayName, defaultValue, rules, enableXssProtection }: FieldSpec);
|
|
40
49
|
parseVal(val: any): any;
|
|
41
50
|
isEqual(val1: any, val2: any): boolean;
|
|
42
51
|
private processRuleSpecs;
|
|
@@ -46,11 +55,11 @@ export declare class Field {
|
|
|
46
55
|
* @param val - raw value to parse.
|
|
47
56
|
* @param type - data type of the field to use for possible conversion.
|
|
48
57
|
* @param defaultValue - typed value to return if `val` undefined or null.
|
|
49
|
-
* @param
|
|
50
|
-
*
|
|
58
|
+
* @param enableXssProtection - true to enable XSS (cross-site scripting) protection.
|
|
59
|
+
* See {@link FieldSpec.enableXssProtection} for additional details.
|
|
51
60
|
* @returns resulting value, potentially parsed or cast as per type.
|
|
52
61
|
*/
|
|
53
|
-
export declare function parseFieldValue(val: any, type: FieldType, defaultValue?: any,
|
|
62
|
+
export declare function parseFieldValue(val: any, type: FieldType, defaultValue?: any, enableXssProtection?: boolean): any;
|
|
54
63
|
/** Data types for Fields used within Hoist Store Records and Cubes. */
|
|
55
64
|
export declare const FieldType: Readonly<{
|
|
56
65
|
TAGS: "tags";
|
|
@@ -12,7 +12,7 @@ export interface StoreConfig {
|
|
|
12
12
|
* Default configs applied to `Field` instances constructed internally by this Store.
|
|
13
13
|
* @see FieldSpec
|
|
14
14
|
*/
|
|
15
|
-
fieldDefaults?:
|
|
15
|
+
fieldDefaults?: Omit<FieldSpec, 'name'>;
|
|
16
16
|
/**
|
|
17
17
|
* Specification for producing an immutable unique id for each record. May be provided as
|
|
18
18
|
* either a string property name (default is 'id') or a function that receives the raw data
|
|
@@ -81,7 +81,7 @@ export interface QueryConfig {
|
|
|
81
81
|
*
|
|
82
82
|
* This can be used to break selected aggregations into sub-groups dynamically, without having
|
|
83
83
|
* to define another dimension in the Cube and have it apply to all aggregations. See the
|
|
84
|
-
* {@link BucketSpec} interface for additional information.
|
|
84
|
+
* {@link BucketSpecFn} type and {@link BucketSpec} interface for additional information.
|
|
85
85
|
*
|
|
86
86
|
* Defaults to {@link Cube.bucketSpecFn}.
|
|
87
87
|
*/
|
package/core/AppSpec.ts
CHANGED
|
@@ -71,12 +71,19 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
71
71
|
disableWebSockets?: boolean;
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* True to
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* True to enable Field-level XSS protection by default across all Stores/Fields in the app.
|
|
75
|
+
* Available as an extra precaution for use with apps that might display arbitrary input from
|
|
76
|
+
* untrusted or external users. This feature does exact a minor performance penalty during data
|
|
77
|
+
* parsing, which can be significant in aggregate for very large stores containing records with
|
|
78
|
+
* many `string` fields.
|
|
79
|
+
*
|
|
80
|
+
* Note: this flag and its default behavior was changed as of Hoist v77 to be `false`, i.e.
|
|
81
|
+
* Store-level XSS protection *disabled* by default, in keeping with Hoist's primary use-case:
|
|
82
|
+
* building secured internal apps with large datasets and tight performance tolerances.
|
|
83
|
+
*
|
|
84
|
+
* @see FieldSpec.enableXssProtection
|
|
78
85
|
*/
|
|
79
|
-
|
|
86
|
+
enableXssProtection?: boolean;
|
|
80
87
|
|
|
81
88
|
/**
|
|
82
89
|
* True to show a login form on initialization when not authenticated. Default is `false` as
|
|
@@ -144,7 +151,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
144
151
|
componentClass,
|
|
145
152
|
containerClass,
|
|
146
153
|
disableWebSockets = false,
|
|
147
|
-
|
|
154
|
+
enableXssProtection = false,
|
|
148
155
|
enableLoginForm = false,
|
|
149
156
|
enableLogout = false,
|
|
150
157
|
idlePanel = null,
|
|
@@ -191,7 +198,7 @@ export class AppSpec<T extends HoistAppModel = HoistAppModel> {
|
|
|
191
198
|
this.componentClass = componentClass;
|
|
192
199
|
this.containerClass = containerClass;
|
|
193
200
|
this.disableWebSockets = disableWebSockets;
|
|
194
|
-
this.
|
|
201
|
+
this.enableXssProtection = enableXssProtection;
|
|
195
202
|
this.enableLoginForm = enableLoginForm;
|
|
196
203
|
this.enableLogout = enableLogout;
|
|
197
204
|
this.idlePanel = idlePanel;
|
package/data/Field.ts
CHANGED
|
@@ -36,15 +36,24 @@ export interface FieldSpec {
|
|
|
36
36
|
rules?: RuleLike[];
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* True to
|
|
40
|
-
*
|
|
39
|
+
* True to enable built-in XSS (cross-site scripting) protection to all incoming String values
|
|
40
|
+
* using {@link https://github.com/cure53/DOMPurify | DOMPurify}.
|
|
41
41
|
*
|
|
42
42
|
* DOMPurify provides fast escaping of dangerous HTML, scripting, and other content that can be
|
|
43
43
|
* used to execute XSS attacks, while allowing common and expected HTML and style tags.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
45
|
+
* This feature does exact a minor performance penalty during data parsing, which can be
|
|
46
|
+
* significant in aggregate for very large stores containing records with many `string` fields.
|
|
47
|
+
*
|
|
48
|
+
* For extra safety, apps which are open to potentially-untrusted users or display other
|
|
49
|
+
* potentially dangerous string content can opt into this setting app-wide via
|
|
50
|
+
* {@link AppSpec.enableXssProtection}. Field-level setting will override any app-level default.
|
|
51
|
+
*
|
|
52
|
+
* Note: this flag and its default behavior was changed as of Hoist v77 to be `false`, i.e.
|
|
53
|
+
* Store-level XSS protection *disabled* by default, in keeping with Hoist's primary use-case:
|
|
54
|
+
* building secured internal apps with large datasets and tight performance tolerances.
|
|
46
55
|
*/
|
|
47
|
-
|
|
56
|
+
enableXssProtection?: boolean;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/** Metadata for an individual data field within a {@link StoreRecord}. */
|
|
@@ -58,7 +67,7 @@ export class Field {
|
|
|
58
67
|
readonly displayName: string;
|
|
59
68
|
readonly defaultValue: any;
|
|
60
69
|
readonly rules: Rule[];
|
|
61
|
-
readonly
|
|
70
|
+
readonly enableXssProtection: boolean;
|
|
62
71
|
|
|
63
72
|
constructor({
|
|
64
73
|
name,
|
|
@@ -66,19 +75,19 @@ export class Field {
|
|
|
66
75
|
displayName,
|
|
67
76
|
defaultValue = null,
|
|
68
77
|
rules = [],
|
|
69
|
-
|
|
78
|
+
enableXssProtection = XH.appSpec.enableXssProtection
|
|
70
79
|
}: FieldSpec) {
|
|
71
80
|
this.name = name;
|
|
72
81
|
this.type = type;
|
|
73
82
|
this.displayName = withDefault(displayName, genDisplayName(name));
|
|
74
83
|
this.defaultValue = defaultValue;
|
|
75
84
|
this.rules = this.processRuleSpecs(rules);
|
|
76
|
-
this.
|
|
85
|
+
this.enableXssProtection = enableXssProtection;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
parseVal(val: any): any {
|
|
80
|
-
const {type, defaultValue,
|
|
81
|
-
return parseFieldValue(val, type, defaultValue,
|
|
89
|
+
const {type, defaultValue, enableXssProtection} = this;
|
|
90
|
+
return parseFieldValue(val, type, defaultValue, enableXssProtection);
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
isEqual(val1: any, val2: any): boolean {
|
|
@@ -102,35 +111,30 @@ export class Field {
|
|
|
102
111
|
* @param val - raw value to parse.
|
|
103
112
|
* @param type - data type of the field to use for possible conversion.
|
|
104
113
|
* @param defaultValue - typed value to return if `val` undefined or null.
|
|
105
|
-
* @param
|
|
106
|
-
*
|
|
114
|
+
* @param enableXssProtection - true to enable XSS (cross-site scripting) protection.
|
|
115
|
+
* See {@link FieldSpec.enableXssProtection} for additional details.
|
|
107
116
|
* @returns resulting value, potentially parsed or cast as per type.
|
|
108
117
|
*/
|
|
109
118
|
export function parseFieldValue(
|
|
110
119
|
val: any,
|
|
111
120
|
type: FieldType,
|
|
112
121
|
defaultValue: any = null,
|
|
113
|
-
|
|
122
|
+
enableXssProtection: boolean = XH.appSpec.enableXssProtection
|
|
114
123
|
): any {
|
|
115
124
|
if (val === undefined || val === null) val = defaultValue;
|
|
116
125
|
if (val === null) return val;
|
|
117
126
|
|
|
118
|
-
const sanitizeValue = v => {
|
|
119
|
-
if (disableXssProtection || !isString(v)) return v;
|
|
120
|
-
return DOMPurify.sanitize(v);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
127
|
switch (type) {
|
|
124
128
|
case 'tags':
|
|
125
129
|
val = castArray(val);
|
|
126
130
|
val = val.map(v => {
|
|
127
|
-
v =
|
|
131
|
+
v = !enableXssProtection || !isString(v) ? v : DOMPurify.sanitize(v);
|
|
128
132
|
return v.toString();
|
|
129
133
|
});
|
|
130
134
|
return val;
|
|
131
135
|
case 'auto':
|
|
132
136
|
case 'json':
|
|
133
|
-
return
|
|
137
|
+
return !enableXssProtection || !isString(val) ? val : DOMPurify.sanitize(val);
|
|
134
138
|
case 'int':
|
|
135
139
|
val = toNumber(val);
|
|
136
140
|
return isFinite(val) ? Math.trunc(val) : null;
|
|
@@ -140,7 +144,7 @@ export function parseFieldValue(
|
|
|
140
144
|
return !!val;
|
|
141
145
|
case 'pwd':
|
|
142
146
|
case 'string':
|
|
143
|
-
val =
|
|
147
|
+
val = !enableXssProtection || !isString(val) ? val : DOMPurify.sanitize(val);
|
|
144
148
|
return val.toString();
|
|
145
149
|
case 'date':
|
|
146
150
|
return isDate(val) ? val : new Date(val);
|
package/data/Store.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface StoreConfig {
|
|
|
44
44
|
* Default configs applied to `Field` instances constructed internally by this Store.
|
|
45
45
|
* @see FieldSpec
|
|
46
46
|
*/
|
|
47
|
-
fieldDefaults?:
|
|
47
|
+
fieldDefaults?: Omit<FieldSpec, 'name'>;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Specification for producing an immutable unique id for each record. May be provided as
|
|
@@ -978,17 +978,20 @@ export class Store extends HoistBase {
|
|
|
978
978
|
this.summaryRecords = null;
|
|
979
979
|
}
|
|
980
980
|
|
|
981
|
-
private parseFields(
|
|
981
|
+
private parseFields(
|
|
982
|
+
fields: Array<string | FieldSpec | Field>,
|
|
983
|
+
defaults: Omit<FieldSpec, 'name'>
|
|
984
|
+
): Field[] {
|
|
982
985
|
const ret = fields.map(f => {
|
|
983
986
|
if (f instanceof Field) return f;
|
|
984
987
|
|
|
985
|
-
|
|
988
|
+
let fieldSpec: FieldSpec = isString(f) ? {name: f} : f;
|
|
986
989
|
|
|
987
990
|
if (!isEmpty(defaults)) {
|
|
988
|
-
|
|
991
|
+
fieldSpec = defaultsDeep({}, fieldSpec, defaults);
|
|
989
992
|
}
|
|
990
993
|
|
|
991
|
-
return new this.defaultFieldClass(
|
|
994
|
+
return new this.defaultFieldClass(fieldSpec);
|
|
992
995
|
});
|
|
993
996
|
|
|
994
997
|
throwIf(
|
package/data/cube/Query.ts
CHANGED
|
@@ -109,7 +109,7 @@ export interface QueryConfig {
|
|
|
109
109
|
*
|
|
110
110
|
* This can be used to break selected aggregations into sub-groups dynamically, without having
|
|
111
111
|
* to define another dimension in the Cube and have it apply to all aggregations. See the
|
|
112
|
-
* {@link BucketSpec} interface for additional information.
|
|
112
|
+
* {@link BucketSpecFn} type and {@link BucketSpec} interface for additional information.
|
|
113
113
|
*
|
|
114
114
|
* Defaults to {@link Cube.bucketSpecFn}.
|
|
115
115
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "77.0.0-SNAPSHOT.
|
|
3
|
+
"version": "77.0.0-SNAPSHOT.1761229417098",
|
|
4
4
|
"description": "Hoist add-on for building and deploying React Applications.",
|
|
5
5
|
"repository": "github:xh/hoist-react",
|
|
6
6
|
"homepage": "https://xh.io",
|