@pbvision/cloud-run-service 0.0.46
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/.dockerignore +10 -0
- package/.env +9 -0
- package/.eslintrc.json +51 -0
- package/.gcloudignore +9 -0
- package/.github/workflows/check-pr.yml +33 -0
- package/.github/workflows/publish-to-github-npm.yml +36 -0
- package/.vscode/extensions.json +9 -0
- package/.vscode/settings.json +39 -0
- package/Dockerfile +18 -0
- package/LICENSE +201 -0
- package/README.md +49 -0
- package/babel.config.cjs +12 -0
- package/docs/cloudbuild/preview.example.yaml +76 -0
- package/docs/cloudbuild/release.example.yaml +74 -0
- package/jest.config.json +24 -0
- package/package.json +69 -0
- package/src/analytics.js +164 -0
- package/src/app.js +31 -0
- package/src/call-service-api.js +25 -0
- package/src/index.js +17 -0
- package/src/main.js +92 -0
- package/src/placeholder.js +71 -0
- package/src/port.js +2 -0
- package/src/tasks.js +117 -0
- package/src/utils.js +44 -0
- package/test/base-test.js +17 -0
- package/test/main.js +4 -0
- package/test/unit-test-analytics.js +280 -0
- package/test/unit-test-call-service-api.js +96 -0
- package/test/unit-test-placeholder.js +10 -0
- package/test/unit-test-tasks.js +133 -0
- package/test/unit-test-utils.js +44 -0
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pbvision/cloud-run-service",
|
|
3
|
+
"version": "0.0.46",
|
|
4
|
+
"description": "fastify-firestore-service Web Framework on Cloud Run",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"exports": "./src/index.js",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"coverage": "yarn -s start-local-db && yarn -s test --coverage",
|
|
13
|
+
"debug": "yarn start-local-db && INDEBUGGER=1 ./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./test --exec 'node --inspect=9229 --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json --runInBand'",
|
|
14
|
+
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
|
15
|
+
"setup": "yarn install --frozen-lockfile",
|
|
16
|
+
"start-local-db": "./node_modules/@pbvision/firestore-orm/scripts/start-local-db.sh",
|
|
17
|
+
"start-local": "yarn -s start-local-db && K_REVISION=localhost PORT=8090 node --env-file=.env test/main.js",
|
|
18
|
+
"test": "yarn -s start-local-db && yarn -s test-without-starting-db",
|
|
19
|
+
"test-without-starting-db": "node --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json",
|
|
20
|
+
"watch": "yarn -s test --watch"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/pbv-public/cloud-run-service"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"registry": "https://registry.npmjs.org"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@google-cloud/tasks": "^4.0.1",
|
|
31
|
+
"@pbvision/fastify-firestore-service": "^0.0.50",
|
|
32
|
+
"google-auth-library": "^9.4.2",
|
|
33
|
+
"ua-parser-js": "^1.0.37"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@babel/core": "^7.17.12",
|
|
37
|
+
"@babel/eslint-parser": "^7.17.0",
|
|
38
|
+
"@babel/preset-env": "^7.17.12",
|
|
39
|
+
"@pbvision/jest-unit-test": "^0.2.3",
|
|
40
|
+
"babel-loader": "^9.1.3",
|
|
41
|
+
"eslint": "^8.22.0",
|
|
42
|
+
"eslint-config-standard": "17.1.0",
|
|
43
|
+
"eslint-import-resolver-webpack": "^0.13.8",
|
|
44
|
+
"eslint-plugin-import": "^2.22.0",
|
|
45
|
+
"eslint-plugin-n": "^16.6.2",
|
|
46
|
+
"eslint-plugin-node": "^11.1.0",
|
|
47
|
+
"eslint-plugin-promise": "^6.0.0",
|
|
48
|
+
"firebase-tools": "^13.5.2",
|
|
49
|
+
"jest": "^29.7.0",
|
|
50
|
+
"nodemon": "^3.1.0",
|
|
51
|
+
"standard": "^17.1.0",
|
|
52
|
+
"superagent": "^8",
|
|
53
|
+
"superagent-defaults": "^0.1.14",
|
|
54
|
+
"supertest": "^6.3.4",
|
|
55
|
+
"webpack": "^5.89.0"
|
|
56
|
+
},
|
|
57
|
+
"standard": {
|
|
58
|
+
"envs": [
|
|
59
|
+
"jest"
|
|
60
|
+
],
|
|
61
|
+
"globals": [
|
|
62
|
+
"fail"
|
|
63
|
+
],
|
|
64
|
+
"ignore": [
|
|
65
|
+
"**/node_modules/**"
|
|
66
|
+
],
|
|
67
|
+
"parser": "@babel/eslint-parser"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/analytics.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import crypto, { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { DatabaseAPI, EXCEPTIONS } from '@pbvision/fastify-firestore-service'
|
|
5
|
+
import UAParser from 'ua-parser-js'
|
|
6
|
+
|
|
7
|
+
import { isProd } from './utils.js'
|
|
8
|
+
|
|
9
|
+
// istanbul ignore next
|
|
10
|
+
export const mixpanelToken = isProd ? '78c48e38f59ab21c1850740e2bb4ecff' : '52bd993b07bdba759c2f141345e7c32a'
|
|
11
|
+
|
|
12
|
+
const mixpanelUpdateProfileURLs = {
|
|
13
|
+
$set: 'https://api.mixpanel.com/engage#profile-set',
|
|
14
|
+
$set_once: 'https://api.mixpanel.com/engage#profile-set-once'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Analytics are only sent if the transaction commits. Aborting or throwing an
|
|
18
|
+
// exception from the tx will result in analytics NOT being sent to mixpanel.
|
|
19
|
+
export class DatabaseAPIWithAnalytics extends DatabaseAPI {
|
|
20
|
+
constructor (fastify, req, reply) {
|
|
21
|
+
super(fastify, req, reply)
|
|
22
|
+
this.__analyticsEvents = []
|
|
23
|
+
this.__analyticsUserProfileUpdates = {} // uid to $set/$set_once to changes
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async postCommit (respData) {
|
|
27
|
+
// send analytics events after committing
|
|
28
|
+
await this.sendAnalyticsEvents()
|
|
29
|
+
return super.postCommit(respData)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
logAnalyticsEvent (mixpanelUserId, eventName, inputProperties = {}, deviceId = null, insertId = null) {
|
|
33
|
+
if (insertId) {
|
|
34
|
+
// insert id must be <= 36 chars & only have alphanumeric & hyphen chars
|
|
35
|
+
insertId = crypto.createHash('md5').update(insertId).digest('hex')
|
|
36
|
+
} else {
|
|
37
|
+
insertId = randomUUID()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.__analyticsEvents.push({
|
|
41
|
+
event: eventName,
|
|
42
|
+
properties: addSenderId(mixpanelUserId, {
|
|
43
|
+
...inputProperties,
|
|
44
|
+
token: mixpanelToken,
|
|
45
|
+
time: new Date().getTime(),
|
|
46
|
+
$insert_id: insertId,
|
|
47
|
+
ip: this.req.ip
|
|
48
|
+
}, deviceId)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
updateAnalyticsUserProfile (uid, key, value, method = '$set') {
|
|
53
|
+
assert(mixpanelUpdateProfileURLs[method])
|
|
54
|
+
// should be our user id not a device id... profile data is not recommended
|
|
55
|
+
// for anonymous users
|
|
56
|
+
assert(!uid.startsWith('$device:'))
|
|
57
|
+
if (!this.__analyticsUserProfileUpdates[method]) {
|
|
58
|
+
this.__analyticsUserProfileUpdates[method] = {}
|
|
59
|
+
}
|
|
60
|
+
const updatesByUser = this.__analyticsUserProfileUpdates[method]
|
|
61
|
+
if (!updatesByUser[uid]) {
|
|
62
|
+
updatesByUser[uid] = {}
|
|
63
|
+
}
|
|
64
|
+
updatesByUser[uid][key] = value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async sendAnalyticsEvents () {
|
|
68
|
+
const events = this.__analyticsEvents
|
|
69
|
+
this.__analyticsEvents = []
|
|
70
|
+
const userProfileUpdates = this.__analyticsUserProfileUpdates
|
|
71
|
+
this.__analyticsUserProfileUpdates = {}
|
|
72
|
+
|
|
73
|
+
const promises = []
|
|
74
|
+
if (events.length) {
|
|
75
|
+
const uaData = {}
|
|
76
|
+
const parser = new UAParser(this.req.headers['user-agent'])
|
|
77
|
+
const browser = parser.getBrowser()
|
|
78
|
+
if (browser.name) {
|
|
79
|
+
uaData.$browser = browser.name
|
|
80
|
+
// istanbul ignore else
|
|
81
|
+
if (browser.version) {
|
|
82
|
+
uaData.$browser += ` ${browser.version}`
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const device = parser.getDevice()
|
|
86
|
+
const $device = [device.vendor, device.model, device.type].filter(x => !!x).join(' ')
|
|
87
|
+
if ($device) {
|
|
88
|
+
uaData.$device = $device
|
|
89
|
+
}
|
|
90
|
+
const os = parser.getOS()
|
|
91
|
+
if (os.name) {
|
|
92
|
+
uaData.$os = os.name
|
|
93
|
+
// istanbul ignore else
|
|
94
|
+
if (os.version) {
|
|
95
|
+
uaData.$os += ` ${os.version}`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const x of events) {
|
|
100
|
+
Object.assign(x.properties, uaData)
|
|
101
|
+
}
|
|
102
|
+
// send all the events in one Mixpanel API call
|
|
103
|
+
promises.push(this.callAPI({
|
|
104
|
+
method: 'POST',
|
|
105
|
+
url: 'https://api.mixpanel.com/track',
|
|
106
|
+
headers: { accept: 'text/plain' },
|
|
107
|
+
body: events
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
const sent = []
|
|
111
|
+
for (const type of Object.keys(userProfileUpdates)) {
|
|
112
|
+
const updatesByUser = userProfileUpdates[type]
|
|
113
|
+
const body = []
|
|
114
|
+
for (const uid of Object.keys(updatesByUser)) {
|
|
115
|
+
const updates = updatesByUser[uid]
|
|
116
|
+
body.push({
|
|
117
|
+
$distinct_id: uid,
|
|
118
|
+
$token: mixpanelToken,
|
|
119
|
+
[type]: updates
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
promises.push(this.__sendUserProfileUpdates(type, body))
|
|
123
|
+
sent.push({ type, body })
|
|
124
|
+
}
|
|
125
|
+
const responses = await Promise.all(promises)
|
|
126
|
+
for (let i = 0; i < responses.length; i++) {
|
|
127
|
+
const resp = responses[i]
|
|
128
|
+
if (!resp.isOk || resp.data !== 1) {
|
|
129
|
+
console.log('error response from mixpanel', resp, sent)
|
|
130
|
+
throw new EXCEPTIONS.RequestError(
|
|
131
|
+
'failed to log analytics', { resp, sent }, 551)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async __sendUserProfileUpdates (type, body) {
|
|
137
|
+
const mixpanelApiURL = mixpanelUpdateProfileURLs[type]
|
|
138
|
+
assert(mixpanelApiURL) // make sure a valid type was passed
|
|
139
|
+
return this.callAPI({
|
|
140
|
+
method: 'POST',
|
|
141
|
+
url: mixpanelApiURL,
|
|
142
|
+
headers: { accept: 'text/plain' },
|
|
143
|
+
body
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addSenderId (mixpanelUserId, properties, deviceId) {
|
|
149
|
+
if (mixpanelUserId.startsWith('$device:')) {
|
|
150
|
+
properties.$device_id = mixpanelUserId.substring(8)
|
|
151
|
+
} else {
|
|
152
|
+
properties.$user_id = mixpanelUserId
|
|
153
|
+
if (deviceId) {
|
|
154
|
+
// istanbul ignore else
|
|
155
|
+
if (deviceId.startsWith('$device')) {
|
|
156
|
+
properties.$device_id = deviceId.substring(8)
|
|
157
|
+
} else {
|
|
158
|
+
properties.$device_id = deviceId
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
properties.distinct_id = mixpanelUserId
|
|
163
|
+
return properties
|
|
164
|
+
}
|
package/src/app.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { makeService } from '@pbvision/fastify-firestore-service'
|
|
2
|
+
|
|
3
|
+
import { port } from './port.js'
|
|
4
|
+
import { isProd } from './utils.js'
|
|
5
|
+
|
|
6
|
+
export async function makePBVService (components, customizePinoOpts) {
|
|
7
|
+
return makeService({
|
|
8
|
+
service: process.env.SERVICE,
|
|
9
|
+
components,
|
|
10
|
+
cookie: {
|
|
11
|
+
secret: process.env.COOKIE_SECRET
|
|
12
|
+
},
|
|
13
|
+
healthCheck: {
|
|
14
|
+
path: '/_healthcheck'
|
|
15
|
+
},
|
|
16
|
+
latencyTracker: {
|
|
17
|
+
disabled: isProd
|
|
18
|
+
},
|
|
19
|
+
logging: {
|
|
20
|
+
customizePinoOpts,
|
|
21
|
+
reportErrorDetail: !isProd,
|
|
22
|
+
reportAllErrors: true,
|
|
23
|
+
sentryDSN: process.env.SENTRY_DSN
|
|
24
|
+
},
|
|
25
|
+
swagger: {
|
|
26
|
+
disabled: isProd,
|
|
27
|
+
servers: [`http://localhost:${port}`],
|
|
28
|
+
routePrefix: '/app/docs'
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// add helper method to call APIs on this or other services; if the service is
|
|
2
|
+
// internal then we'll make the request with our authorization token (only
|
|
3
|
+
// works if this service has been granted access to the target service!)
|
|
4
|
+
import { GoogleAuth } from 'google-auth-library'
|
|
5
|
+
|
|
6
|
+
import { getServiceProtocolAndHost } from './utils.js'
|
|
7
|
+
|
|
8
|
+
const auth = new GoogleAuth()
|
|
9
|
+
|
|
10
|
+
// This function will be added to the API class.
|
|
11
|
+
export async function callServiceAPI ({
|
|
12
|
+
path, service: serviceName,
|
|
13
|
+
body = undefined, qsParams = undefined,
|
|
14
|
+
method = 'POST', headers = {}, isServiceInternal = true
|
|
15
|
+
}) {
|
|
16
|
+
const protocolAndHost = getServiceProtocolAndHost(serviceName)
|
|
17
|
+
const url = `${protocolAndHost}${path}`
|
|
18
|
+
if (isServiceInternal) {
|
|
19
|
+
const targetAudience = `${protocolAndHost}/`
|
|
20
|
+
const client = await auth.getIdTokenClient(targetAudience)
|
|
21
|
+
const token = await client.idTokenProvider.fetchIdToken(targetAudience)
|
|
22
|
+
headers.Authorization = `Bearer ${token}`
|
|
23
|
+
}
|
|
24
|
+
return this.callAPI({ method, headers, url, body, qsParams })
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DatabaseAPIWithAnalytics, mixpanelToken } from './analytics.js'
|
|
2
|
+
import { makeService, runService } from './main.js'
|
|
3
|
+
import { enqueueCloudTask } from './tasks.js'
|
|
4
|
+
import { isCloud, isDev, isProd, isLocalhost, isUnitTesting, getServiceProtocolAndHost } from './utils.js'
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
enqueueCloudTask,
|
|
8
|
+
makeService, // for use with unit testing
|
|
9
|
+
runService,
|
|
10
|
+
|
|
11
|
+
// analytics
|
|
12
|
+
DatabaseAPIWithAnalytics, mixpanelToken,
|
|
13
|
+
|
|
14
|
+
// utility
|
|
15
|
+
getServiceProtocolAndHost,
|
|
16
|
+
isCloud, isDev, isProd, isLocalhost, isUnitTesting
|
|
17
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import { API } from '@pbvision/fastify-firestore-service'
|
|
4
|
+
|
|
5
|
+
import { makePBVService } from './app.js'
|
|
6
|
+
import { callServiceAPI } from './call-service-api.js'
|
|
7
|
+
import { port } from './port.js'
|
|
8
|
+
import { isCloud, isLocalhost, isUnitTesting } from './utils.js'
|
|
9
|
+
|
|
10
|
+
API.prototype.callServiceAPI = callServiceAPI
|
|
11
|
+
|
|
12
|
+
let service
|
|
13
|
+
const project = process.env.GCLOUD_PROJECT
|
|
14
|
+
|
|
15
|
+
function verifyEnvironmentVariables () {
|
|
16
|
+
const requiredEnvKeys = [
|
|
17
|
+
'GCLOUD_PROJECT', 'K_REVISION', 'NODE_ENV', 'REGION', 'SERVICE']
|
|
18
|
+
// istanbul ignore else
|
|
19
|
+
if (isLocalhost) {
|
|
20
|
+
assert(['localhost', 'unittest'].indexOf(process.env.K_REVISION) !== -1,
|
|
21
|
+
'K_REVISION must be "localhost" or "unittest" in this environment')
|
|
22
|
+
} else {
|
|
23
|
+
// must have GIT_HASH to send to sentry in the cloud
|
|
24
|
+
requiredEnvKeys.push('GIT_HASH')
|
|
25
|
+
}
|
|
26
|
+
for (const k of requiredEnvKeys) {
|
|
27
|
+
assert(process.env[k], `${k} environment variable must be set`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeCustomizeLoggingOptionsFunction () {
|
|
32
|
+
return options => {
|
|
33
|
+
options.formatters = {
|
|
34
|
+
level (label) {
|
|
35
|
+
return { severity: label }
|
|
36
|
+
},
|
|
37
|
+
// set messageKey to "message" for automatic parsing by GCP logs
|
|
38
|
+
messageKey: 'message'
|
|
39
|
+
}
|
|
40
|
+
const originalReqSerializer = options.serializers.req
|
|
41
|
+
options.serializers.req = req => {
|
|
42
|
+
const reqLog = originalReqSerializer(req)
|
|
43
|
+
|
|
44
|
+
// include the trace ID so logging can coordinate multiple logs from the
|
|
45
|
+
// same request per: https://github.com/GoogleCloudPlatform/cloud-run-microservice-template-nodejs/blob/main/utils/logging.js
|
|
46
|
+
const traceHeader = req.headers['X-Cloud-Trace-Context']
|
|
47
|
+
let trace
|
|
48
|
+
// istanbul ignore if
|
|
49
|
+
if (traceHeader) {
|
|
50
|
+
const [traceId] = traceHeader.split('/')
|
|
51
|
+
trace = `projects/${project}/traces/${traceId}`
|
|
52
|
+
reqLog['logging.googleapis.com/trace'] = trace
|
|
53
|
+
}
|
|
54
|
+
return reqLog
|
|
55
|
+
}
|
|
56
|
+
return options
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function makeService (components) {
|
|
61
|
+
verifyEnvironmentVariables()
|
|
62
|
+
return makePBVService(components, makeCustomizeLoggingOptionsFunction())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// istanbul ignore next
|
|
66
|
+
export async function runService (components) {
|
|
67
|
+
if (isCloud) {
|
|
68
|
+
// if the instance tells us it will shutdown, try to shut down gracefully (for
|
|
69
|
+
// example flushing logs)
|
|
70
|
+
process.on('SIGTERM', () => {
|
|
71
|
+
if (!service) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
// cloud run sends SIGTERM 10sec before killing the instance, so give the
|
|
75
|
+
// instance a little more time to finish any current requests then close
|
|
76
|
+
// the fastify instance (which kills any remaining requests, and should
|
|
77
|
+
// flush the logs and other clean up, if time permits)
|
|
78
|
+
setTimeout(() =>
|
|
79
|
+
service.close().then(() => {
|
|
80
|
+
console.log('successfully closed!')
|
|
81
|
+
}, (err) => {
|
|
82
|
+
console.log('an error happened', err)
|
|
83
|
+
}), 7000)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
if (!isUnitTesting) {
|
|
87
|
+
// start the server
|
|
88
|
+
service = await makeService(components)
|
|
89
|
+
service.listen({ port, host: '0.0.0.0' })
|
|
90
|
+
return service
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// APIs in this file are not included in Docker builds so they never are
|
|
2
|
+
// shipped to Cloud Run. They only exist for local testing.
|
|
3
|
+
import assert from 'node:assert'
|
|
4
|
+
|
|
5
|
+
import { API, DatabaseAPI } from '@pbvision/fastify-firestore-service'
|
|
6
|
+
import db from '@pbvision/firestore-orm'
|
|
7
|
+
import S from '@pbvision/schema'
|
|
8
|
+
|
|
9
|
+
import { isLocalhost, isUnitTesting } from './utils.js'
|
|
10
|
+
|
|
11
|
+
import { DatabaseAPIWithAnalytics } from './index.js'
|
|
12
|
+
|
|
13
|
+
class Test extends db.Model {
|
|
14
|
+
static KEY = { id: S.str }
|
|
15
|
+
static FIELDS = { x: S.int }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class TestAPI extends DatabaseAPI {
|
|
19
|
+
static METHOD = 'GET'
|
|
20
|
+
static PATH = '/time'
|
|
21
|
+
static DESC = 'Just for testing'
|
|
22
|
+
static RESPONSE = {
|
|
23
|
+
epoch: S.int
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async computeResponse () {
|
|
27
|
+
const doesNotExist = await this.tx.get(Test, 'abc')
|
|
28
|
+
assert(doesNotExist === undefined)
|
|
29
|
+
return { epoch: Math.floor(new Date().getTime() / 1000) }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class TestCallServiceAPI extends API {
|
|
34
|
+
static PATH = '/callService'
|
|
35
|
+
static DESC = 'This is used by unit tests only to test callServiceAPI.'
|
|
36
|
+
static BODY = S.obj()
|
|
37
|
+
static RESPONSE = { code: S.int, body: S.str }
|
|
38
|
+
|
|
39
|
+
async computeResponse () {
|
|
40
|
+
assert(isUnitTesting)
|
|
41
|
+
const resp = await this.callServiceAPI(this.req.body)
|
|
42
|
+
return {
|
|
43
|
+
code: resp.code,
|
|
44
|
+
body: typeof resp.data === 'object' ? JSON.stringify(resp.data) : (resp.data ?? '')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class TestAnalyticsAPI extends DatabaseAPIWithAnalytics {
|
|
50
|
+
static PATH = '/analytics'
|
|
51
|
+
static DESC = 'This is used by unit tests only to test DatabaseAPIWithAnalytics.'
|
|
52
|
+
static BODY = S.obj()
|
|
53
|
+
|
|
54
|
+
async computeResponse () {
|
|
55
|
+
// istanbul ignore next
|
|
56
|
+
assert(isUnitTesting || isLocalhost)
|
|
57
|
+
const { eventCalls, profileUpdates, moreEventCalls } = this.req.body
|
|
58
|
+
for (const x of (eventCalls ?? [])) {
|
|
59
|
+
this.logAnalyticsEvent(...x)
|
|
60
|
+
}
|
|
61
|
+
for (const x of (profileUpdates ?? [])) {
|
|
62
|
+
this.updateAnalyticsUserProfile(...x)
|
|
63
|
+
}
|
|
64
|
+
if (moreEventCalls) {
|
|
65
|
+
await this.sendAnalyticsEvents()
|
|
66
|
+
for (const x of moreEventCalls) {
|
|
67
|
+
this.logAnalyticsEvent(...x)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/port.js
ADDED
package/src/tasks.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { CloudTasksClient } from '@google-cloud/tasks'
|
|
5
|
+
import { credentials } from '@grpc/grpc-js'
|
|
6
|
+
|
|
7
|
+
import { getServiceProtocolAndHost, usingEmulator } from './utils.js'
|
|
8
|
+
|
|
9
|
+
const tasksClient = (() => {
|
|
10
|
+
// istanbul ignore else
|
|
11
|
+
if (usingEmulator) {
|
|
12
|
+
return new CloudTasksClient({
|
|
13
|
+
port: process.env.CLOUD_TASKS_EMULATOR_PORT,
|
|
14
|
+
servicePath: 'localhost',
|
|
15
|
+
sslCreds: credentials.createInsecure()
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
// istanbul ignore next
|
|
19
|
+
return new CloudTasksClient()
|
|
20
|
+
})()
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Add a Task to a Cloud Tasks queue.
|
|
24
|
+
*
|
|
25
|
+
* The task will be routed to the internal API service on a path that matches
|
|
26
|
+
* the queue name (with hyphens replaced by underscores).
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} task the task to enqueue
|
|
29
|
+
* @param {string} queue the name of the queue to add the task to
|
|
30
|
+
* @param {any} payload the data to convert to JSON add send as the task's body
|
|
31
|
+
* @param {string} [name] if provided, a name that is reused (on the same queue)
|
|
32
|
+
* within about an hour will be rejected (TaskNameAlreadyExistsError)
|
|
33
|
+
* @param {Array<string>} [hashNameParts] if provided, the name param will be
|
|
34
|
+
* generated from the combination of the queue name (namespacing this name)
|
|
35
|
+
* and the name part(s) in this argument; the task name will be an md5 hash
|
|
36
|
+
* of all this
|
|
37
|
+
* @param {string} [service="internal"] the service name that the task request
|
|
38
|
+
* will be routed to
|
|
39
|
+
* @param {boolean} [ignoreNameAlreadyUsedError=false] if true, no error is
|
|
40
|
+
* thrown due to a name already having been used
|
|
41
|
+
* @returns {boolean} true if a new task was added; false if the task name was
|
|
42
|
+
* already recently used (no new task added, but a task was recently added
|
|
43
|
+
* with this name)
|
|
44
|
+
*/
|
|
45
|
+
export async function enqueueCloudTask ({
|
|
46
|
+
queue, payload,
|
|
47
|
+
service = 'internal',
|
|
48
|
+
name = undefined,
|
|
49
|
+
hashNameParts = undefined,
|
|
50
|
+
ignoreNameAlreadyUsedError = false,
|
|
51
|
+
delaySecs = 0,
|
|
52
|
+
scheduledEpoch = undefined // can only be up to 30 days from now
|
|
53
|
+
}) {
|
|
54
|
+
const project = process.env.GCLOUD_PROJECT
|
|
55
|
+
const region = process.env.REGION
|
|
56
|
+
const parent = tasksClient.queuePath(project, region, queue)
|
|
57
|
+
const protocolAndHost = getServiceProtocolAndHost(service)
|
|
58
|
+
const task = {
|
|
59
|
+
httpRequest: {
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json'
|
|
62
|
+
},
|
|
63
|
+
httpMethod: 'POST',
|
|
64
|
+
url: `${protocolAndHost}/${queue.replace(/-/g, '_')}`,
|
|
65
|
+
body: Buffer.from(JSON.stringify(payload)).toString('base64'),
|
|
66
|
+
oidcToken: {
|
|
67
|
+
serviceAccountEmail: `cr-${process.env.SERVICE}@${project}.iam.gserviceaccount.com`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const currentEpoch = new Date().getTime() / 1000
|
|
72
|
+
if (delaySecs) {
|
|
73
|
+
task.scheduleTime = {
|
|
74
|
+
seconds: Math.ceil(currentEpoch + delaySecs)
|
|
75
|
+
}
|
|
76
|
+
assert(!scheduledEpoch, 'cannot specify both delaysSecs and scheduledEpoch')
|
|
77
|
+
}
|
|
78
|
+
if (scheduledEpoch) {
|
|
79
|
+
scheduledEpoch = Math.floor(scheduledEpoch)
|
|
80
|
+
task.scheduleTime = { seconds: Math.floor(scheduledEpoch) }
|
|
81
|
+
}
|
|
82
|
+
if (task.scheduleTime) {
|
|
83
|
+
assert(task.scheduleTime.seconds <= currentEpoch + 30 * 86400, 'cannot delay for more than 30 days')
|
|
84
|
+
}
|
|
85
|
+
if (hashNameParts) {
|
|
86
|
+
assert(Array.isArray(hashNameParts))
|
|
87
|
+
assert(!name, 'cannot specify both name and hashNameParts')
|
|
88
|
+
const namespacedName = [queue, ...hashNameParts].join('|')
|
|
89
|
+
name = crypto.createHash('md5').update(namespacedName).digest('hex')
|
|
90
|
+
}
|
|
91
|
+
if (name) {
|
|
92
|
+
const fqName = tasksClient.taskPath(project, region, queue, name)
|
|
93
|
+
task.name = fqName
|
|
94
|
+
}
|
|
95
|
+
const request = { parent, task }
|
|
96
|
+
try {
|
|
97
|
+
await tasksClient.createTask(request)
|
|
98
|
+
return true
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.code === 6 && e.message.startsWith('6 ALREADY_EXISTS')) {
|
|
101
|
+
if (ignoreNameAlreadyUsedError) {
|
|
102
|
+
return false
|
|
103
|
+
} else {
|
|
104
|
+
throw new TaskNameAlreadyExistsError(name, e)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw e
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class TaskNameAlreadyExistsError extends Error {
|
|
112
|
+
constructor (name, e) {
|
|
113
|
+
super(`task name already used recently: ${name}`)
|
|
114
|
+
this.taskName = name
|
|
115
|
+
this.originalError = e
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import { port as portForThisService } from './port.js'
|
|
4
|
+
|
|
5
|
+
// this refers to where the code is running; the database and other services
|
|
6
|
+
// we are connected to depends on GCLOUD_PROJECT
|
|
7
|
+
export const isLocalhost = process.env.NODE_ENV === 'localhost'
|
|
8
|
+
export const isCloud = !isLocalhost
|
|
9
|
+
export const isDev = process.env.NODE_ENV === 'dev'
|
|
10
|
+
export const isProd = process.env.NODE_ENV === 'prod'
|
|
11
|
+
// istanbul ignore next
|
|
12
|
+
assert(isLocalhost || isDev || isProd,
|
|
13
|
+
'invalid NODE_ENV: must be on localhost, dev or prod')
|
|
14
|
+
|
|
15
|
+
export const isUnitTesting = process.env.K_REVISION === 'unittest'
|
|
16
|
+
assert(!isUnitTesting || isLocalhost, 'must be on localhost if unit testing')
|
|
17
|
+
|
|
18
|
+
export const usingEmulator = !!process.env.FIRESTORE_EMULATOR_HOST
|
|
19
|
+
assert(!usingEmulator || process.env.CLOUD_TASKS_EMULATOR_PORT,
|
|
20
|
+
'both firestore and tasks need to be either emulated or not')
|
|
21
|
+
assert(usingEmulator === (process.env.GCLOUD_PROJECT === 'localhost-emulator'),
|
|
22
|
+
'when using the emulator, GCLOUD_PROJECT should be set to localhost-emulator')
|
|
23
|
+
|
|
24
|
+
export function getServiceProtocolAndHost (serviceName) {
|
|
25
|
+
const host = getServiceHost(serviceName)
|
|
26
|
+
// istanbul ignore next
|
|
27
|
+
const protocol = host.startsWith('localhost') ? 'http' : 'https'
|
|
28
|
+
return `${protocol}://${host}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getServiceHost (serviceName) {
|
|
32
|
+
// manually check NODE_ENV for testing purposes
|
|
33
|
+
if (process.env.NODE_ENV === 'localhost') {
|
|
34
|
+
if (process.env.SERVICE === serviceName) {
|
|
35
|
+
return `localhost:${portForThisService}`
|
|
36
|
+
}
|
|
37
|
+
const portMapping = JSON.parse(process.env.LOCAL_SERVICE_PORT_MAP)
|
|
38
|
+
const port = portMapping[serviceName]
|
|
39
|
+
assert(port, `unknown service or missing port for localhost ${serviceName}`)
|
|
40
|
+
return `localhost:${port}`
|
|
41
|
+
} else {
|
|
42
|
+
return serviceName + process.env.CLOUD_RUN_HOSTNAME_SUFFIX
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BaseAppTest, BaseTest, runTests } from '../node_modules/@pbvision/fastify-firestore-service/test/base-test.js'
|
|
2
|
+
|
|
3
|
+
const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
|
|
4
|
+
export {
|
|
5
|
+
BaseTest, runTests
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class AppTest extends BaseAppTest {
|
|
9
|
+
async getMakeServiceFunc () {
|
|
10
|
+
const { makeService } = await import('../src/main.js')
|
|
11
|
+
return () => makeService({
|
|
12
|
+
TestAnalyticsAPI,
|
|
13
|
+
TestAPI,
|
|
14
|
+
TestCallServiceAPI
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
}
|
package/test/main.js
ADDED