@mcpher/gas-fakes 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Bruce Mcpherson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # A proof of concept implementation of Apps Script Environment on Node
2
+
3
+ I use clasp/vscode to develop Google Apps Script (GAS) applications, but when using GAS native services, there's way too much back and fowards to the GAS IDE going while testing. I set myself the ambition of implementing fake version of the GAS runtime environment on Node so I could at least do some testing locally.
4
+
5
+ This is just a proof of concept so I've just implemented a very limited number of services and methods, but the tricky parts are all in place so all that's left is a load of busy work (to which I heartily invite any interested collaborators).
6
+
7
+ ## getting started
8
+
9
+ You can get the package from npm
10
+
11
+ ````
12
+ npm i @mcpher/gas-fakes
13
+ ````
14
+
15
+ The idea is that you can run GAS services (so far implemented) locally on Node, and it will use various Google Workspace APIS to emulate what would happen if you were to run the same thing in the GAS environment.
16
+
17
+
18
+ ### Cloud project
19
+
20
+ You don't have access to the GAS maintained cloud project, so you'll need to create a GCP project to use locally. In order to duplicate the OAuth management handled by GAS, we'll use Application Default Credentials. There are some scripts in this repo to set up and test these. Once you've set up a clud project go to the shells folder and add your project id to setaccount.sh and
21
+
22
+ ### Testing
23
+
24
+ I recommend you use the test project included in the repo to make sure all is set up correctly. It uses a Fake DriveApp service to excercise Auth etc. Just change the fixtures to values present in your own Drive, then npm i && npm test. Note that I use a [unit tester](https://ramblings.mcpher.com/apps-script-test-runner-library-ported-to-node/) that runs in both GAS and Node, so the exact same tests will run in both environments.
25
+
26
+
27
+ ### Pushing to GAS
28
+
29
+ The script togas.sh will move your files to gas - just set the SOURCE and TARGET folders in the script. Make sure you have an appsscript.json manifest in the SOURCE folder, as gas-fakes reads that to handle OAuth on Node.
30
+
31
+ You can write your project to run on Node and call GAS services, and it will also run on the GAS environment with no code changes.
32
+
33
+ ## Approach
34
+
35
+ Google have not made details about the GAS run time public (as far as I know). What we do know is that it used to run on a Java based JavaScript emulator [Rhino](https://ramblings.mcpher.com/gassnippets2/what-javascript-engine-is-apps-script-running-on/) but a few years ago moved to a V8 runtime. Beyond that, we don't know anything much other than it runs on Google Servers somewhere.
36
+
37
+ There were 3 main sticky problems to overcome to get this working
38
+ - GAS is entirely synchronous, whereas the replacement calls to Workspace APIS on Node are all asynchrounous.
39
+ - GAS handles OAuth initialization from the manifest file automatically, whereas we need some additional coding or alternative approaches on Node.
40
+ - The service singletons (eg. DriveApp) are all intialized and available in the global space automatically, whereas in Node they need some post AUTH intialization, sequencing intialization and exposure.
41
+ - GAS iterators aren't the same as standard iterators, as they have a hasNext() method and don't behave in the same way.
42
+
43
+ Beyond that, implementation is just a lot of busy work. Here's how I've dealt with these 3 problems.
44
+
45
+ ### Sync versus Async
46
+
47
+ Although Apps Script supports async/await/promise syntax, it operates in blocking mode. I didn't really want to have to insist on async coding in code targeted at GAS, so I needed to find a way to emulate what the GAS environment probably does.
48
+
49
+ Since asynchonicity is fundamental to Node, there's no real simple way to convert async to sync. However, there is such a thing as a [child-process](https://nodejs.org/api/child_process.html#child-process) which you can start up to run things, and it features an [execSync](https://nodejs.org/api/child_process.html#child_processexecsynccommand-options) method which delays the return from the child process until the promise queue is all settled. So the simplest solution is to run an async method in a child process, wait till it's done, and return the results synchronously. I found that [Sindre Sorhus](https://github.com/sindresorhus) uses this approach with [make-synchronous](https://github.com/sindresorhus/make-synchronous), so I'm using that.
50
+
51
+ Here's a simple example of how to get info on an access token made synchronous
52
+ ````
53
+ /**
54
+ * a sync version of token checking
55
+ * @param {string} token the token to check
56
+ * @returns {object} access token info
57
+ */
58
+ const fxCheckToken = (accessToken) => {
59
+
60
+ // now turn all that into a synchronous function - it runs as a subprocess, so we need to start from scratch
61
+ const fx = makeSynchronous(async accessToken => {
62
+ const { default: got } = await import('got')
63
+ const tokenInfo = await got(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`).json()
64
+ return tokenInfo
65
+ })
66
+
67
+ const result = fx(accessToken)
68
+ return result
69
+ }
70
+ ````
71
+ ### OAuth
72
+
73
+ There's 2 pieces to this solution.
74
+
75
+ #### Application default credentials (ADC)
76
+
77
+ In order to avoid a bunch of Node specific code and credentials, yet still handle OAuth, I figured that we could simply rely on ADC. This is a problem I already wrote about here [Application Default Credentials with Google Cloud and Workspace APIs](https://ramblings.mcpher.com/application-default-credentials-with-google-cloud-and-workspace-apis/)
78
+
79
+ To set this up, set your GCP project ID and the extra scopes you'll need in shells/setaccount.sh. In this example I'm retaining the usual ADC scopes, and adding an extra scope to be able to access Drive.
80
+
81
+ ````
82
+ # project ID
83
+ P=YOUR_GCP_PROJECT_ID
84
+
85
+ # config to activate - multiple configs can each be named
86
+ # here we're working on the default project configuration
87
+ AC=default
88
+
89
+ # these are the ones it sets by default - take some of these out if you want to minimize access
90
+ DEFAULT_SCOPES="https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/drive,openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/sqlservice.login"
91
+
92
+ # these are the ones we want to add (note comma at beginning)
93
+ EXTRA_SCOPES=",https://www.googleapis.com/auth/drive"
94
+
95
+ .....etc
96
+ ````
97
+ Now you can execute this and it will set up your ADC to be able to run any services that require the scopes you add.
98
+
99
+ ##### note
100
+ Although you may be tempted to add "https://www.googleapis.com/auth/script.external_request", it's not necessary for the ADC and in fact will generate an error. You will of course need it in your Apps script manifest.
101
+
102
+ ##### testing ADC
103
+
104
+ shells/testtoken.sh can test that you can generate a token with sufficient scope. In this example, I'm checking that I can access a file I own. Change the id to one of your own.
105
+ ````
106
+ # check tokens have scopes required for DRIVE access
107
+ # set below to a fileid on drive you have access to
108
+ FILE_ID=SOME_FILE_ID
109
+
110
+ ....etc
111
+ ````
112
+
113
+ I recommend you do this to make sure Auth it's all good before you start coding up your app.
114
+
115
+
116
+ #### Manifest file
117
+
118
+ gas-fakes reads the manifest file to see which scopes you need in your project, uses the Google Auth library to attempt to authorizes them and has ScriptApp.getOauthToken() return a sufficiently specced token, just as the GAS environment does. Just make sure you have an appsscript.json in the same folder as your main script.
119
+
120
+ ### global intialization
121
+
122
+ This was a little problematic to sequence, but I wanted to make sure that any GAS services being imitated were available and initialized on the Node side, just as they are in GAS. At the time of writing (a subset of the methods of) these services are implemented.
123
+
124
+ v1.0.0 proof of concept for
125
+
126
+ - DriveApp
127
+ - ScriptApp
128
+ - UrlFetchApp
129
+ - Utilities
130
+
131
+
132
+ #### Proxies and globalThis
133
+
134
+ Each service has a FakeClass but I needed the Auth cycle to be initiated and done before making them public. Using a proxy was the simplest approach.
135
+
136
+ Here's the code for ScriptApp
137
+
138
+ ````
139
+ /**
140
+ * adds to global space to mimic Apps Script behavior
141
+ */
142
+ const name = "ScriptApp"
143
+
144
+ if (typeof globalThis[name] === typeof undefined) {
145
+
146
+ console.log ('setting script app to global')
147
+
148
+ const getApp = () => {
149
+
150
+ // if it hasn't been intialized yet then do that
151
+ if (!_app) {
152
+
153
+ // we also need to do the manifest scopes thing and the project id
154
+ const projectId = Syncit.fxGetProjectId()
155
+ const manifest = Syncit.fxGetManifest()
156
+ Auth.setProjectId (projectId)
157
+ Auth.setManifestScopes(manifest)
158
+
159
+ _app = {
160
+ getOAuthToken,
161
+ requireAllScopes,
162
+ requireScopes,
163
+ AuthMode: {
164
+ FULL: 'FULL'
165
+ }
166
+ }
167
+
168
+
169
+ }
170
+ // this is the actual driveApp we'll return from the proxy
171
+ return _app
172
+ }
173
+
174
+
175
+ Proxies.registerProxy(name, getApp)
176
+
177
+ }
178
+ ````
179
+
180
+ Here's how the proxies are registered
181
+ ````
182
+
183
+ /**
184
+ * diverts the property get to another object returned by the getApp function
185
+ * @param {function} a function to get the proxy object to substitutes
186
+ * @returns {function} a handler for a proxy
187
+ */
188
+ const getAppHandler = (getApp) => {
189
+ return {
190
+
191
+ get(_, prop, receiver) {
192
+ // this will let the caller know we're not really running in Apps Script
193
+ return (prop === 'isFake') ? true : Reflect.get(getApp(), prop, receiver);
194
+ },
195
+
196
+ ownKeys(_) {
197
+ return Reflect.ownKeys(getApp())
198
+ }
199
+ }
200
+ }
201
+
202
+ const registerProxy = (name, getApp) => {
203
+ const value = new Proxy({}, getAppHandler(getApp))
204
+ // add it to the global space to mimic what apps script does
205
+ Object.defineProperty(globalThis, name, {
206
+ value,
207
+ enumerable: true,
208
+ configurable: false,
209
+ writable: false,
210
+ });
211
+ }
212
+ ````
213
+
214
+ In short, the service us registered as an empty object, but when any attempt is made to access it actually returns a different object which handles the request. In the ScriptApp example, ScriptApp is an empty object, but accessing ScriptApp.getOAuthToken() returns an Fake ScriptApp object which has been initialized.
215
+
216
+ There's also a test available to see if you are running in GAS or on Node - ScriptApp.isFake
217
+
218
+ ### Iterators
219
+
220
+ An iterator created by a generator does not have a hasNext() function, whereas GAS iterators do. To get round this, we can create a regular Node iterator, but introduce a wrapper so the constructor actually gets the first one, and next() uses the value we've already peeked at. Here's a wrapper to convert an iterator into a GAS style one.
221
+ ````
222
+ import { Proxies } from './proxies.js'
223
+ /**
224
+ * this is a class to add a hasnext to a generator
225
+ * @class Peeker
226
+ *
227
+ */
228
+ class Peeker {
229
+ /**
230
+ * @constructor
231
+ * @param {function} generator the generator function to add a hasNext() to
232
+ * @returns {Peeker}
233
+ */
234
+ constructor(generator) {
235
+ this.generator = generator
236
+ // in order to be able to do a hasnext we have to actually get the value
237
+ // this is the next value stored
238
+ this.peeked = generator.next()
239
+ }
240
+
241
+ /**
242
+ * we see if there's a next if the peeked at is all over
243
+ * @returns {Boolean}
244
+ */
245
+ hasNext () {
246
+ return !this.peeked.done
247
+ }
248
+
249
+ /**
250
+ * get the next value - actually its already got and storef in peeked
251
+ * @returns {object} {value, done}
252
+ */
253
+ next () {
254
+ if (!this.hasNext()) {
255
+ // TODO find out what driveapp does
256
+ throw new Error ('iterator is exhausted - there is no more')
257
+ }
258
+ // instead of returning the next, we return the prepeeked next
259
+ const value = this.peeked.value
260
+ this.peeked = this.generator.next()
261
+ return value
262
+ }
263
+ }
264
+
265
+ export const newPeeker = (...args) => Proxies.guard(new Peeker (...args))
266
+ ````
267
+
268
+ And an example of usage, creating a parents iterator from a Drive API file.
269
+ ````
270
+ /**
271
+ * this gets an intertor to fetch all the parents meta data
272
+ * @param {FakeDriveMeta} {file} the meta data
273
+ * @returns {object} {Peeker}
274
+ */
275
+ const getParentsIterator = ({
276
+ file
277
+ }) => {
278
+
279
+ Utils.assertType(file, "object")
280
+ Utils.assertType(file.parents, "array")
281
+
282
+ function* filesink() {
283
+ // the result tank, we just get them all by id
284
+ let tank = file.parents.map(id => getFileById({ id, allow404: false }))
285
+
286
+ while (tank.length) {
287
+ yield newFakeDriveFolder(tank.splice(0, 1)[0])
288
+ }
289
+ }
290
+
291
+ // create the iterator
292
+ const parentsIt = filesink()
293
+
294
+ // a regular iterator doesnt support the same methods
295
+ // as Apps Script so we'll fake that too
296
+ return newPeeker(parentsIt)
297
+
298
+ }
299
+ ````
300
+ ## Help
301
+
302
+ As I mentioned earlier, to take this further, I'm going to need a lot of help to extend the methods and services supported - so if you feel this would be useful to you, and would like to collaborate, please ping me on @bruce@mcpher.com and we'll talk.
303
+
304
+
305
+
package/main.js ADDED
@@ -0,0 +1 @@
1
+ import './src/index.js'
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "dependencies": {
3
+ "get-stream": "^9.0.1",
4
+ "google-auth-library": "^9.15.0",
5
+ "googleapis": "^144.0.0",
6
+ "got": "^14.4.5",
7
+ "make-synchronous": "^1.0.0",
8
+ "mime": "^4.0.6",
9
+ "sleep-synchronously": "^2.0.0"
10
+ },
11
+ "type": "module",
12
+ "scripts": {
13
+ "test": "node ./test/test.js",
14
+ "pub": "npm publish --access public"
15
+ },
16
+ "devDependencies": {
17
+ "@mcpher/unit": "^1.1.6"
18
+ },
19
+ "name": "@mcpher/gas-fakes",
20
+ "version": "1.0.0",
21
+ "main": "main.js",
22
+ "description": "A proof of concept implementation of Apps Script Environment on Node"
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import './services/scriptapp/app.js'
2
+ import './services/drive/app.js'
3
+ import './services/urlfetchapp/app.js'
4
+ import './services/utilities/app.js'
@@ -0,0 +1,31 @@
1
+ // fake Apps Script DriveApp
2
+ /**
3
+ * the idea here is to create a global entry for the singleton
4
+ * before we actually have everything we need to create it.
5
+ * We do this by using a proxy, intercepting calls to the
6
+ * initial sigleton and diverting them to a completed one
7
+ */
8
+ import { newFakeDriveApp} from './fakedrive.js'
9
+ import { Proxies } from '../../support/proxies.js'
10
+
11
+ // This will eventually hold a proxy for DriveApp
12
+ let _app = null
13
+
14
+ /**
15
+ * adds to global space to mimic Apps Script behavior
16
+ */
17
+ const name = "DriveApp"
18
+ if (typeof globalThis[name] === typeof undefined) {
19
+
20
+ const getApp = () => {
21
+ // if it hasne been intialized yet then do that
22
+ if (!_app) {
23
+ _app = newFakeDriveApp()
24
+ }
25
+ // this is the actual driveApp we'll return from the proxy
26
+ return _app
27
+ }
28
+
29
+ Proxies.registerProxy (name, getApp)
30
+
31
+ }
@@ -0,0 +1,23 @@
1
+ import { google } from "googleapis";
2
+
3
+ let driveClient = null
4
+
5
+ export const getDriveClient = (auth) => {
6
+ if (!driveClient) {
7
+ driveClient = google.drive({version: 'v3', auth});
8
+ }
9
+ return driveClient
10
+ }
11
+
12
+ /**
13
+ * we can't serialize a return object from drive api
14
+ * so we just select a few props from it
15
+ * @param {SyncDriveResponse} result
16
+ * @returns
17
+ */
18
+ export const responseSyncify = (result) => ({
19
+ status: result.status,
20
+ statusText: result.statusText,
21
+ responseUrl: result.request?.responseURL
22
+ })
23
+