@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/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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ // istanbul ignore next
2
+ export const port = process.env.PORT ?? 8080
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
@@ -0,0 +1,4 @@
1
+ import { runService } from '../src/index.js'
2
+ const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
3
+
4
+ await runService({ TestAnalyticsAPI, TestAPI, TestCallServiceAPI })