Skip to content

Commit

Permalink
Initial release (#1)
Browse files Browse the repository at this point in the history
* Initial setup and source code

* Add docs to Readme
  • Loading branch information
enuchi authored Jul 22, 2020
1 parent c1d2033 commit 3df57d9
Show file tree
Hide file tree
Showing 8 changed files with 3,287 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}
29 changes: 29 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"root": true,
"parser": "babel-eslint",
"extends": ["airbnb-base", "plugin:prettier/recommended"],
"plugins": ["babel", "prettier"],
"env": {
"browser": true,
"es6": true
},
"globals": {
"google": false,
"alert": false,
"css": true
},
"parserOptions": {
"ecmaVersion": 9,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"rules": {
"prettier/prettier": "error",
"camelcase": "warn",
"import/prefer-default-export": "warn",
"import/no-extraneous-dependencies": "warn",
"prefer-object-spread": "warn"
}
}
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# mac
.DS_Store

# build
build/
package*/

# Logs
logs
*.log
Expand Down
3 changes: 3 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"singleQuote": true
}
122 changes: 122 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# gas-client

A client-side utility class that uses promises to call server-side Google Apps Script functions. This is a user-friendly wrapper of [google.script.run](https://developers.google.com/apps-script/guides/html/reference/run).

It can also optionally be used in local development and is designed to interact with the [Google Apps Script Dev Server](https://github.com/enuchi/Google-Apps-Script-Webpack-Dev-Server) used in the [React / Google Apps Script](https://github.com/enuchi/React-Google-Apps-Script) project.

---

## Installation

Install
```bash
> npm install gas-client
# or
> yarn add gas-client
```

```javascript
import Server from 'gas-client';
const { serverFunctions } = new Server();

// We now have access to all our server functions, which return promises
serverFunctions
.addSheet(sheetTitle)
.then((response) => doSomething(response))
.catch((err) => handleError(err));
```

### Development mode

To use with [Google Apps Script Dev Server](https://github.com/enuchi/Google-Apps-Script-Webpack-Dev-Server), pass in a config object with `allowedDevelopmentDomains` indicating the localhost port you are using. This setting will be ignored in production (see below for more details).

```javascript
import Server from 'gas-client';

const { serverFunctions } = new Server({
allowedDevelopmentDomains: 'https://localhost:3000',
});

serverFunctions
.addSheet(sheetTitle)
.then((response) => doSomething(response))
.catch((err) => handleError(err));
```

---

## How to use

### Using the gas-client utility class

The `gas-client` file lets you use promises to call and handle responses from the server, instead of using `google.script.run`:

```javascript
// Google's client-side utility "google.script.run" works like this:
google.script.run
.withSuccessHandler((response) => doSomething(response))
.withFailureHandler((err) => handleError(err))
.addSheet(sheetTitle);
```

```javascript
// With this package we can now do this:
import Server from 'gas-client';
const { serverFunctions } = new Server();

// We now have access to all our server functions, which return promises
serverFunctions
.addSheet(sheetTitle)
.then((response) => doSomething(response))
.catch((err) => handleError(err));

// Or we can use async/await syntax:
async () => {
try {
const response = await serverFunctions.addSheet(sheetTitle);
doSomething(response);
} catch (err) {
handleError(err);
}
};
```

Now we can use familiar Promises in our client-side code and have easy access to all server functions.

---

## API

The config object takes:
`allowedDevelopmentDomains`: A config to specifiy which domains are permitted for communication with Google Apps Script Webpack Dev Server development tool. This is a security setting, and if not specified, will block functionality in development.

`allowedDevelopmentDomains` will accept either a space-separated string of allowed subdomains, e.g. `'https://localhost:3000 https://localhost:8080'` (notice no trailing slashes); or a function that takes in the requesting origin and should return `true` to allow communication, e.g. `(origin) => /localhost:\d+$/.test(origin);`

### Production mode

In the normal Google Apps Script production environment, `new Server()` will have one available method:

- `serverFunctions`: an object containing all publicly exposed server functions (see example above).

Note that the `allowedDevelopmentDomains` configuration will be ignored in production, so the same code can and should be used for development and production.

### Development mode

Development mode for the `gas-client` helper class will be run when:

1. the `google` client API cannot be found, i.e. an error is thrown with the message "ReferenceError: google is not defined", and

2. a `process.env.NODE_ENV` variable is set to `'development'`. [webpack.DefinePlugin](https://webpack.js.org/plugins/define-plugin/) can be set this up like this:

```javascript
plugins: [
// ...,
new webpack.DefinePlugin({
'process.env': JSON.stringify({ NODE_ENV: 'development' }),
}),
];
```

Calling `new Server({ allowedDevelopmentDomains })` will create an instance with the following method in development mode:

- `serverFunctions`: a proxy object, used for development purposes, that mimics calling `google.script.run`. It will dispatch a message to the parent iframe (our custom Dev Server), which will call an app that actually interacts with the `google.script.run` API. Development mode will also handle the response and resolve or reject based on the response type. See the implementation for details on the event signature.
44 changes: 44 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "gas-client",
"version": "0.1.0",
"description": "A client-side utility class that can call server-side Google Apps Script functions",
"main": "build/index.js",
"files": [
"build",
"src"
],
"repository": "https://github.com/enuchi/gas-client.git",
"author": "Elisha Nuchi",
"license": "MIT",
"scripts": {
"prepack": "yarn build",
"build": "babel src/index.js -d build"
},
"bugs": {
"url": "https://github.com/enuchi/gas-client/issues"
},
"homepage": "https://github.com/enuchi/gas-client#readme",
"keywords": [
"Goole",
"Apps",
"Script",
"GAS",
"Client"
],
"devDependencies": {
"@babel/cli": "^7.10.5",
"@babel/core": "^7.10.5",
"@babel/preset-env": "^7.10.4",
"babel-eslint": "^10.1.0",
"eslint": "^7.5.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^3.1.4",
"prettier": "^2.0.5"
},
"dependencies": {
"uuid": "^8.2.0"
}
}
119 changes: 119 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { v4 as uuidv4 } from 'uuid';

/**
* Util that returns true if allowedDevelopmentDomains matches origin
* @param {string|function} allowedDevelopmentDomains either a string of space-separated allowed subdomains or a function that accepts the origin as an argument and returns true if permitted
* @param {string} origin the target origin subdomain to compare against
*/
const checkAllowList = (allowedDevelopmentDomains, origin) => {
if (typeof allowedDevelopmentDomains === 'string') {
return allowedDevelopmentDomains
.split(' ')
.some((permittedOrigin) => permittedOrigin === origin);
}
if (typeof allowedDevelopmentDomains === 'function') {
return allowedDevelopmentDomains(origin) === true;
}
return false;
};

export default class Server {
/**
* Accepts a single `config` object
* @param {object} [config] An optional config object for use in development.
* @param {string|function} [config.allowedDevelopmentDomains] An optional config to specify which domains are permitted for communication with Google Apps Script Webpack Dev Server development tool. This is a security setting, and if not specified, this will block functionality in development. Will accept either a space-separated string of allowed subdomains, e.g. `https://localhost:3000 http://localhost:3000` (notice no trailing slash); or a function that takes in the requesting origin should return `true` to allow communication, e.g. `(origin) => /localhost:\d+$/.test(origin)`
*/
constructor(config = {}) {
// skip the reserved names: https://developers.google.com/apps-script/guides/html/reference/run
const ignoredFunctionNames = [
'withFailureHandler',
'withLogger',
'withSuccessHandler',
'withUserObject',
];

this.serverFunctions = {};

try {
// get the names of all of our publicly accessible server functions
const functionNames = Object.keys(google.script.run).filter(
// filter out the reserved names -- we don't want those
(functionName) => !ignoredFunctionNames.includes(functionName)
);

// attach Promise-based functions to the serverFunctions property
functionNames.forEach((functionName) => {
this.serverFunctions[functionName] = (...args) =>
new Promise((resolve, reject) => {
google.script.run
.withSuccessHandler(resolve)
.withFailureHandler(reject)
[functionName](...args);
});
});
} catch (err) {
if (
err.toString() === 'ReferenceError: google is not defined' &&
process &&
process.env &&
process.env.NODE_ENV === 'development'
) {
// we'll store and access the resolve/reject functions here by id
window.gasStore = {};

// set up the message 'receive' handler
const receiveMessageHandler = (event) => {
const { allowedDevelopmentDomains } = config;

// check the allow list for the receiving origin
const allowOrigin = checkAllowList(
allowedDevelopmentDomains,
event.origin
);
if (!allowOrigin) return;

// we only care about the type: 'RESPONSE' messages here
if (event.data.type !== 'RESPONSE') return;

const { response, status, id } = event.data;

// look up the saved resolve and reject funtions in our global store based
// on the response id, and call the function depending on the response status
const { resolve, reject } = window.gasStore[id];

if (status === 'ERROR') {
reject(response);
}
resolve(response);
};

window.addEventListener('message', receiveMessageHandler, false);

const handler = {
get(target, functionName) {
const id = uuidv4();
const promise = new Promise((resolve, reject) => {
// store the new Promise's resolve and reject
window.gasStore[id] = { resolve, reject };
});
return (...args) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
window.parent.postMessage(
{
type: 'REQUEST',
id,
functionName,
args: [...args],
},
// only send messages to our dev server, which should be running on the same origin
window.location.origin
);
return promise;
};
},
};
this.serverFunctions = new Proxy({}, handler);
}
}
}
}
Loading

0 comments on commit 3df57d9

Please sign in to comment.