[ua-client-hints] Refactor UAClientHints

This commit is contained in:
Faisal Salman 2023-08-26 04:55:07 +07:00
parent f538018f8e
commit 1522691426
13 changed files with 466 additions and 311 deletions

10
package-lock.json generated
View File

@ -23,7 +23,7 @@
], ],
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"src/client-hints-helpers", "src/ua-client-hints",
"src/user-agent-helpers" "src/user-agent-helpers"
], ],
"devDependencies": { "devDependencies": {
@ -768,6 +768,10 @@
"resolved": "src/client-hints-helpers", "resolved": "src/client-hints-helpers",
"link": true "link": true
}, },
"node_modules/@ua-parser-js/ua-client-hints": {
"resolved": "src/ua-client-hints",
"link": true
},
"node_modules/@ua-parser-js/user-agent-helpers": { "node_modules/@ua-parser-js/user-agent-helpers": {
"resolved": "src/user-agent-helpers", "resolved": "src/user-agent-helpers",
"link": true "link": true
@ -3794,6 +3798,10 @@
"extraneous": true, "extraneous": true,
"license": "MIT" "license": "MIT"
}, },
"src/ua-client-hints": {
"version": "0.0.1",
"license": "MIT"
},
"src/user-agent-helpers": { "src/user-agent-helpers": {
"name": "@ua-parser-js/user-agent-helpers", "name": "@ua-parser-js/user-agent-helpers",
"version": "0.0.1", "version": "0.0.1",

View File

@ -219,7 +219,7 @@
} }
], ],
"workspaces": [ "workspaces": [
"src/client-hints-helpers", "src/ua-client-hints",
"src/user-agent-helpers" "src/user-agent-helpers"
] ]
} }

View File

@ -54,9 +54,9 @@ const modules = [
replacements : [] replacements : []
}, },
{ {
src : 'src/client-hints-helpers/client-hints-helpers.js', src : 'src/ua-client-hints/ua-client-hints.js',
dest : 'src/client-hints-helpers/client-hints-helpers.mjs', dest : 'src/ua-client-hints/ua-client-hints.mjs',
title : '@ua-parser-js/client-hints-helpers', title : '@ua-parser-js/ua-client-hints',
replacements : [] replacements : []
} }
]; ];

View File

@ -1,35 +0,0 @@
interface IBrowser {
brand: string;
version: string;
}
interface ClientHintsJSLowEntropy {
brands: Array<IBrowser>;
mobile: boolean;
platform: string;
}
export interface ClientHintsJSHighEntropy extends ClientHintsJSLowEntropy {
architecture?: string;
bitness?: string;
formFactor?: string;
fullVersionList?: Array<IBrowser>;
model?: string;
platformVersion?: string;
wow64?: boolean;
};
export interface ClientHintsHTTPHeaders {
'sec-ch-ua-arch'?: string;
'sec-ch-ua-bitness'?: string;
'sec-ch-ua'?: string;
'sec-ch-ua-form-factor'?: string;
'sec-ch-ua-full-version-list'?: string;
'sec-ch-ua-mobile'?: string;
'sec-ch-ua-model'?: string;
'sec-ch-ua-platform'?: string;
'sec-ch-ua-platform-version'?: string;
'sec-ch-ua-wow64'?: string;
}
export function UACHParser(headers: ClientHintsHTTPHeaders): ClientHintsJSHighEntropy;

View File

@ -1,88 +0,0 @@
////////////////////////////////////////////////////
/* A collection of utility methods for client-hints
https://github.com/faisalman/ua-parser-js
Author: Faisal Salman <f@faisalman.com>
MIT License */
///////////////////////////////////////////////////
/*jshint esversion: 11 */
const UACHMap = {
'sec-ch-ua-arch' : {
prop : 'architecture',
type : 'sf-string'
},
'sec-ch-ua-bitness' : {
prop : 'bitness',
type : 'sf-string'
},
'sec-ch-ua' : {
prop : 'brands',
type : 'sf-list'
},
'sec-ch-ua-form-factor' : {
prop : 'formFactor',
type : 'sf-string'
},
'sec-ch-ua-full-version-list' : {
prop : 'fullVersionList',
type : 'sf-list'
},
'sec-ch-ua-mobile' : {
prop : 'mobile',
type : 'sf-boolean',
},
'sec-ch-ua-model' : {
prop : 'model',
type : 'sf-string',
},
'sec-ch-ua-platform' : {
prop : 'platform',
type : 'sf-string'
},
'sec-ch-ua-platform-version' : {
prop : 'platformVersion',
type : 'sf-string'
},
'sec-ch-ua-wow64' : {
prop : 'wow64',
type : 'sf-boolean'
}
};
const UACHParser = (headers) => {
const parse = (str, type) => {
if (!str) {
return '';
}
switch (type) {
case 'sf-boolean':
return /\?1/.test(str);
case 'sf-list':
return str.replace(/\\?\"/g, '')
.split(', ')
.map(brands => {
const [brand, version] = brands.split(';v=');
return {
brand : brand,
version : version
};
});
case 'sf-string':
default:
return str.replace(/\\?\"/g, '');
}
};
let ch = {};
Object.keys(UACHMap).forEach(field => {
if (headers.hasOwnProperty(field)) {
const { prop, type } = UACHMap[field];
ch[prop] = parse(headers[field], type);
}
});
return ch;
};
module.exports = {
UACHParser
};

View File

@ -1,92 +0,0 @@
// Generated ESM version of @ua-parser-js/client-hints-helpers
// DO NOT EDIT THIS FILE!
// Source: /src/client-hints-helpers/client-hints-helpers.js
////////////////////////////////////////////////////
/* A collection of utility methods for client-hints
https://github.com/faisalman/ua-parser-js
Author: Faisal Salman <f@faisalman.com>
MIT License */
///////////////////////////////////////////////////
/*jshint esversion: 11 */
const UACHMap = {
'sec-ch-ua-arch' : {
prop : 'architecture',
type : 'sf-string'
},
'sec-ch-ua-bitness' : {
prop : 'bitness',
type : 'sf-string'
},
'sec-ch-ua' : {
prop : 'brands',
type : 'sf-list'
},
'sec-ch-ua-form-factor' : {
prop : 'formFactor',
type : 'sf-string'
},
'sec-ch-ua-full-version-list' : {
prop : 'fullVersionList',
type : 'sf-list'
},
'sec-ch-ua-mobile' : {
prop : 'mobile',
type : 'sf-boolean',
},
'sec-ch-ua-model' : {
prop : 'model',
type : 'sf-string',
},
'sec-ch-ua-platform' : {
prop : 'platform',
type : 'sf-string'
},
'sec-ch-ua-platform-version' : {
prop : 'platformVersion',
type : 'sf-string'
},
'sec-ch-ua-wow64' : {
prop : 'wow64',
type : 'sf-boolean'
}
};
const UACHParser = (headers) => {
const parse = (str, type) => {
if (!str) {
return '';
}
switch (type) {
case 'sf-boolean':
return /\?1/.test(str);
case 'sf-list':
return str.replace(/\\?\"/g, '')
.split(', ')
.map(brands => {
const [brand, version] = brands.split(';v=');
return {
brand : brand,
version : version
};
});
case 'sf-string':
default:
return str.replace(/\\?\"/g, '');
}
};
let ch = {};
Object.keys(UACHMap).forEach(field => {
if (headers.hasOwnProperty(field)) {
const { prop, type } = UACHMap[field];
ch[prop] = parse(headers[field], type);
}
});
return ch;
};
export {
UACHParser
};

View File

@ -1,75 +0,0 @@
# @ua-parser-js/client-hints-helpers
This is a [UAParser.js](https://github.com/faisalman/ua-parser-js) module that contains a collection of utility methods for working with user-agent client-hints.
```sh
npm i @ua-parser-js/client-hints-helpers
```
### * `UACHParser(headers:object):object`
Parse user-agent client-hints HTTP headers (sec-ch-ua) into its JS API equivalent
## Code Example
```js
import { UACHParser } from '@ua-parser-js/client-hints-helpers';
/*
Suppose we're in a server having this client hints data:
const headers = {
'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"',
'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"',
'sec-ch-ua-arch' : 'arm',
'sec-ch-ua-bitness' : '64',
'sec-ch-ua-mobile' : '?1',
'sec-ch-ua-model' : 'Pixel 99',
'sec-ch-ua-platform' : 'Linux',
'sec-ch-ua-platform-version' : '13',
'user-agent' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'
};
*/
const userAgentData = UACHParser(headers);
console.log(userAgentData);
/*
{
"architecture": "arm",
"bitness": "64",
"brands": [
{
"brand": "Chromium",
"version": "93"
},
{
"brand": "Google Chrome",
"version": "93"
},
{
"brand": " Not;A Brand",
"version": "99"
}
],
"fullVersionList": [
{
"brand": "Chromium",
"version": "93.0.1.2"
},
{
"brand": "Google Chrome",
"version": "93.0.1.2"
},
{
"brand": " Not;A Brand",
"version": "99.0.1.2"
}
],
"mobile": true,
"model": "Pixel 99",
"platform": "Linux",
"platformVersion": "13"
}
*/
```

View File

@ -1,11 +1,11 @@
{ {
"title": "Client-Hints Helpers", "title": "User-Agent Client Hints",
"name": "@ua-parser-js/client-hints-helpers", "name": "@ua-parser-js/ua-client-hints",
"version": "0.0.1", "version": "0.0.1",
"author": "Faisal Salman <f@faisalman.com>", "author": "Faisal Salman <f@faisalman.com>",
"description": "A collection of utility methods for working with client-hints", "description": "A collection of utility methods for working with user-agent client hints",
"main": "client-hints-helpers.js", "main": "ua-client-hints.js",
"module": "client-hints-helpers.mjs", "module": "ua-client-hints.mjs",
"scripts": { "scripts": {
"test": "mocha ../../test/mocha-test-helpers" "test": "mocha ../../test/mocha-test-helpers"
}, },

View File

@ -0,0 +1,128 @@
# @ua-parser-js/ua-client-hints
This is a [UAParser.js](https://github.com/faisalman/ua-parser-js) module that contains a collection of utility methods for working with user-agent client hints.
```sh
npm i @ua-parser-js/ua-client-hints
```
### * `getUAData([props:array]):object`
Get user-agent client hints values of current instance in form of JS object representation
### * `setUAData([uaData:object]):UAClientHints`
Set values of user-agent client hints for the current instance either from navigator.userAgentData or from HTTP headers (Sec-CH-UA-*)
### * `getSerializedUAData([props:array]):object`
Get user-agent client hints values of current instance in form of HTTP headers string representation (Sec-CH-UA-*)
## Code Example
```js
import { UAClientHints } from '@ua-parser-js/ua-client-hints';
/*
Suppose we're in a server having this client hints data:
const httpHeaders = {
'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"',
'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"',
'sec-ch-ua-arch' : 'arm',
'sec-ch-ua-bitness' : '64',
'sec-ch-ua-mobile' : '?1',
'sec-ch-ua-model' : 'Pixel 99',
'sec-ch-ua-platform' : 'Linux',
'sec-ch-ua-platform-version' : '13'
};
*/
const uaCH = new UAClientHints();
uaCH.setUAData(httpHeaders);
const uaCHData1 = uaCH.getUAData();
const uaCHData2 = uaCH.getUAData(['architecture', 'bitness']);
console.log(uaCHData1);
/*
{
"architecture": "arm",
"bitness": "64",
"brands": [
{
"brand": "Chromium",
"version": "93"
},
{
"brand": "Google Chrome",
"version": "93"
},
{
"brand": " Not;A Brand",
"version": "99"
}
],
"fullVersionList": [
{
"brand": "Chromium",
"version": "93.0.1.2"
},
{
"brand": "Google Chrome",
"version": "93.0.1.2"
},
{
"brand": " Not;A Brand",
"version": "99.0.1.2"
}
],
"mobile": true,
"model": "Pixel 99",
"platform": "Linux",
"platformVersion": "13",
"wow64": null,
"formFactor": null
}
*/
console.log(uaCHData2);
/*
{
"architecture": "arm",
"bitness": "64"
}
*/
uaCH.setUAData({
"wow64" : true,
"formFactor" : "Automotive"
});
const headersData1 = uaCH.getSerializedUAData();
const headersData2 = uaCH.getSerializedUAData(['brand', 'mobile', 'model']);
console.log(headersData1);
/*
{
'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"',
'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"',
'sec-ch-ua-arch' : 'arm',
'sec-ch-ua-bitness' : '64',
'sec-ch-ua-mobile' : '?1',
'sec-ch-ua-model' : 'Pixel 99',
'sec-ch-ua-platform' : 'Linux',
'sec-ch-ua-platform-version' : '13',
'sec-ch-ua-wow64' : '?1',
'sec-ch-ua-form-factor' : 'Automotive'
};
*/
console.log(headersData2);
/*
{
'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"',
'sec-ch-ua-mobile' : '?1',
'sec-ch-ua-model' : 'Pixel 99'
};
*/
```

View File

@ -0,0 +1,39 @@
export type UABrowser = {
brand: string | null,
version: string | null
};
export type UADataType = boolean | string | Array<UABrowser> | null;
export type UADataField =
'brands' |
'mobile' |
'platform' |
'architecture' |
'bitness' |
'formFactor' |
'fullVersionList' |
'model' |
'platformVersion' |
'wow64';
export type HeaderType = 'sf-boolean' | 'sf-string' | 'sf-list';
export type HeaderField =
'sec-ch-ua-arch' |
'sec-ch-ua-bitness' |
'sec-ch-ua' |
'sec-ch-ua-form-factor' |
'sec-ch-ua-full-version-list' |
'sec-ch-ua-mobile' |
'sec-ch-ua-model' |
'sec-ch-ua-platform' |
'sec-ch-ua-platform-version' |
'sec-ch-ua-wow64';
export class UAClientHints {
#ch: Map<UADataField, UADataType>;
#parseHeader(str: string, type: HeaderType): UADataType;
#serialize(data: UADataType, type: HeaderType): string;
getSerializedUAData(): Record<HeaderField, string>;
getUAData(props?: Array<UADataField>): Record<UADataField, UADataType>;
setUAData(uaData: Record<UADataField, UADataType> | Record<HeaderField, string>): UAClientHints;
};

View File

@ -0,0 +1,130 @@
//////////////////////////////////////////////
/* A collection of utility methods for
working with user-agent client hints
https://github.com/faisalman/ua-parser-js
Author: Faisal Salman <f@faisalman.com>
MIT License */
/////////////////////////////////////////////
/*jshint esversion: 11 */
const fieldType = Object.freeze({
Boolean : 'sf-boolean',
List : 'sf-list',
String : 'sf-string'
});
const uaCHMap = Object.freeze({
architecture : {
field : 'Sec-CH-UA-Arch',
type : fieldType.String
},
bitness : {
field : 'Sec-CH-UA-Bitness',
type : fieldType.String
},
brands : {
field : 'Sec-CH-UA',
type : fieldType.List
},
formFactor : {
field : 'Sec-CH-UA-Form-Factor',
type : fieldType.String
},
fullVersionList : {
field : 'Sec-CH-UA-Full-Version-List',
type : fieldType.List
},
mobile : {
field : 'Sec-CH-UA-Mobile',
type : fieldType.Boolean
},
model : {
field : 'Sec-CH-UA-Model',
type : fieldType.String
},
platform : {
field : 'Sec-CH-UA-Platform',
type : fieldType.String
},
platformVersion : {
field : 'Sec-CH-UA-Platform-Version',
type : fieldType.String
},
wow64 : {
field : 'Sec-CH-UA-WOW64',
type : fieldType.Boolean
}
});
class UAClientHints {
#uach = new Map();
constructor () {
for (const key in uaCHMap) {
this.#uach.set(key, null);
}
return this;
};
#parseHeader (str, type) {
if (!str) {
return null;
}
switch (type) {
case fieldType.Boolean:
return /\?1/.test(str);
case fieldType.List:
return str.replace(/\\?\"/g, '')
.split(',')
.map(brands => {
const [brand, version] = brands.trim().split(';v=');
return {
brand : brand,
version : version
};
});
case fieldType.String:
return str.replace(/\s*\\?\"\s*/g, '');
default:
return '';
}
};
#serialize(data, type) {
throw new Error('Not implemented yet');
//return '';
}
getSerializedUAData() {
throw new Error('Not implemented yet');
//let http = {};
//return http;
}
getUAData(props) {
if (props) {
return Object.fromEntries(props.filter(val => this.#uach.get(val)).map(val => [val, this.#uach.get(val)]));
}
return Object.fromEntries(this.#uach);
}
setUAData(uaDataValues) {
if(Object.keys(uaDataValues).some(key => key.startsWith('sec-ch-ua'))) {
for (const val in uaCHMap) {
const { field, type } = uaCHMap[val];
this.#uach.set(val, this.#parseHeader(uaDataValues[field.toLowerCase()], type));
}
} else {
for (const value in uaDataValues) {
if (this.#uach.has(value)) this.#uach.set(value, uaDataValues[value]);
}
}
return this;
};
}
module.exports = {
UAClientHints
};

View File

@ -0,0 +1,134 @@
// Generated ESM version of @ua-parser-js/ua-client-hints
// DO NOT EDIT THIS FILE!
// Source: /src/ua-client-hints/ua-client-hints.js
//////////////////////////////////////////////
/* A collection of utility methods for
working with user-agent client hints
https://github.com/faisalman/ua-parser-js
Author: Faisal Salman <f@faisalman.com>
MIT License */
/////////////////////////////////////////////
/*jshint esversion: 11 */
const fieldType = Object.freeze({
Boolean : 'sf-boolean',
List : 'sf-list',
String : 'sf-string'
});
const uaCHMap = Object.freeze({
architecture : {
field : 'Sec-CH-UA-Arch',
type : fieldType.String
},
bitness : {
field : 'Sec-CH-UA-Bitness',
type : fieldType.String
},
brands : {
field : 'Sec-CH-UA',
type : fieldType.List
},
formFactor : {
field : 'Sec-CH-UA-Form-Factor',
type : fieldType.String
},
fullVersionList : {
field : 'Sec-CH-UA-Full-Version-List',
type : fieldType.List
},
mobile : {
field : 'Sec-CH-UA-Mobile',
type : fieldType.Boolean
},
model : {
field : 'Sec-CH-UA-Model',
type : fieldType.String
},
platform : {
field : 'Sec-CH-UA-Platform',
type : fieldType.String
},
platformVersion : {
field : 'Sec-CH-UA-Platform-Version',
type : fieldType.String
},
wow64 : {
field : 'Sec-CH-UA-WOW64',
type : fieldType.Boolean
}
});
class UAClientHints {
#uach = new Map();
constructor () {
for (const key in uaCHMap) {
this.#uach.set(key, null);
}
return this;
};
#parseHeader (str, type) {
if (!str) {
return null;
}
switch (type) {
case fieldType.Boolean:
return /\?1/.test(str);
case fieldType.List:
return str.replace(/\\?\"/g, '')
.split(',')
.map(brands => {
const [brand, version] = brands.trim().split(';v=');
return {
brand : brand,
version : version
};
});
case fieldType.String:
return str.replace(/\s*\\?\"\s*/g, '');
default:
return '';
}
};
#serialize(data, type) {
throw new Error('Not implemented yet');
//return '';
}
getSerializedUAData() {
throw new Error('Not implemented yet');
//let http = {};
//return http;
}
getUAData(props) {
if (props) {
return Object.fromEntries(props.filter(val => this.#uach.get(val)).map(val => [val, this.#uach.get(val)]));
}
return Object.fromEntries(this.#uach);
}
setUAData(uaDataValues) {
if(Object.keys(uaDataValues).some(key => key.startsWith('sec-ch-ua'))) {
for (const val in uaCHMap) {
const { field, type } = uaCHMap[val];
this.#uach.set(val, this.#parseHeader(uaDataValues[field.toLowerCase()], type));
}
} else {
for (const value in uaDataValues) {
if (this.#uach.has(value)) this.#uach.set(value, uaDataValues[value]);
}
}
return this;
};
}
export {
UAClientHints
};

View File

@ -1,5 +1,5 @@
const { isFrozenUA, unfreezeUA } = require('@ua-parser-js/user-agent-helpers'); const { isFrozenUA, unfreezeUA } = require('@ua-parser-js/user-agent-helpers');
const { UACHParser } = require('@ua-parser-js/client-hints-helpers'); const { UAClientHints } = require('@ua-parser-js/ua-client-hints');
const assert = require('assert'); const assert = require('assert');
describe('isFrozenUA()', () => { describe('isFrozenUA()', () => {
@ -38,12 +38,12 @@ describe('isFrozenUA()', () => {
const headers = { const headers = {
'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"', 'sec-ch-ua' : '"Chromium";v="93", "Google Chrome";v="93", " Not;A Brand";v="99"',
'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"', 'sec-ch-ua-full-version-list' : '"Chromium";v="93.0.1.2", "Google Chrome";v="93.0.1.2", " Not;A Brand";v="99.0.1.2"',
'sec-ch-ua-arch' : 'arm', 'sec-ch-ua-arch' : '"arm"',
'sec-ch-ua-bitness' : '64', 'sec-ch-ua-bitness' : '"64"',
'sec-ch-ua-mobile' : '?1', 'sec-ch-ua-mobile' : '?1',
'sec-ch-ua-model' : 'Pixel 99', 'sec-ch-ua-model' : '"Pixel 99"',
'sec-ch-ua-platform' : 'Linux', 'sec-ch-ua-platform' : '"Linux"',
'sec-ch-ua-platform-version' : '13', 'sec-ch-ua-platform-version' : '"13"',
'user-agent' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36' 'user-agent' : 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36'
}; };
@ -54,9 +54,13 @@ describe('unfreezeUA()', () => {
}); });
}); });
describe('UACHParser()', () => { describe('Parse CH Headers', () => {
it('parse client hints HTTP headers (sec-ch-ua) into a client hints-like JavaScript object', () => { it('parse client hints HTTP headers (sec-ch-ua) into a client hints-like JavaScript object', () => {
assert.deepEqual(UACHParser(headers), { assert.deepEqual(new UAClientHints().setUAData(headers).getUAData(['architecture', 'bitness']), {
"architecture": "arm",
"bitness": "64"
});
assert.deepEqual(new UAClientHints().setUAData(headers).getUAData(), {
"architecture": "arm", "architecture": "arm",
"bitness": "64", "bitness": "64",
"brands": [ "brands": [
@ -69,7 +73,7 @@ describe('UACHParser()', () => {
"version": "93" "version": "93"
}, },
{ {
"brand": " Not;A Brand", "brand": "Not;A Brand",
"version": "99" "version": "99"
} }
], ],
@ -83,14 +87,16 @@ describe('UACHParser()', () => {
"version": "93.0.1.2" "version": "93.0.1.2"
}, },
{ {
"brand": " Not;A Brand", "brand": "Not;A Brand",
"version": "99.0.1.2" "version": "99.0.1.2"
} }
], ],
"formFactor": null,
"mobile": true, "mobile": true,
"model": "Pixel 99", "model": "Pixel 99",
"platform": "Linux", "platform": "Linux",
"platformVersion": "13" "platformVersion": "13",
"wow64": null
}); });
}); });
}); });