@edgedev/firebase 2.1.79 → 2.2.80

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/src/functions.js CHANGED
@@ -1,3 +1,4 @@
1
1
  // START @edge/firebase functions
2
2
  exports.edgeFirebase = require('./edgeFirebase')
3
+ exports.cms = require('./cms')
3
4
  // END @edge/firebase functions
package/src/index.js CHANGED
@@ -2,4 +2,5 @@ require('dotenv').config({ path: process.env.NODE_ENV === 'production' ? '.env.p
2
2
 
3
3
  // START @edge/firebase functions
4
4
  exports.edgeFirebase = require('./edgeFirebase')
5
- // END @edge/firebase functions
5
+ exports.cms = require('./cms')
6
+ // END @edge/firebase functions
@@ -0,0 +1,146 @@
1
+ // kvClient.js
2
+ const axios = require('axios')
3
+ const FormData = require('form-data')
4
+
5
+ const accountId = process.env.CF_ACCOUNT_ID
6
+ const namespaceId = process.env.CLOUDFLARE_NAMESPACE_ID
7
+ const apiKey = process.env.CLOUDFLARE_API_KEY
8
+
9
+ if (!accountId || !namespaceId || !apiKey) {
10
+ console.warn('[kvClient] Missing CF env vars: CF_ACCOUNT_ID, CLOUDFLARE_NAMESPACE_ID, CLOUDFLARE_API_KEY')
11
+ }
12
+
13
+ const base = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`
14
+
15
+ function parseJsonIfNeeded(body) {
16
+ if (body === null || body === undefined) {
17
+ return null
18
+ }
19
+ if (typeof body === 'object') {
20
+ return body
21
+ }
22
+ if (typeof body === 'string' && body.trim().length > 0) {
23
+ try {
24
+ return JSON.parse(body)
25
+ }
26
+ catch (e) {
27
+ console.warn('[kvClient] Failed to parse JSON body:', body.slice(0, 200))
28
+ return null
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
34
+ function withWriteParams(url, opts) {
35
+ if (!opts) {
36
+ return url
37
+ }
38
+ const u = new URL(url)
39
+ if (opts.expiration_ttl != null) {
40
+ u.searchParams.set('expiration_ttl', String(opts.expiration_ttl))
41
+ }
42
+ if (opts.expiration != null) {
43
+ u.searchParams.set('expiration', String(opts.expiration))
44
+ }
45
+ return u.toString()
46
+ }
47
+
48
+ /**
49
+ * put(key, value, opts?)
50
+ * opts: { metadata?: object, expiration?: number, expiration_ttl?: number }
51
+ */
52
+ async function put(key, value, opts = undefined) {
53
+ const url0 = `${base}/values/${encodeURIComponent(key)}`
54
+ const url = withWriteParams(url0, opts)
55
+ const headers = { Authorization: `Bearer ${apiKey}` }
56
+
57
+ if (opts && opts.metadata) {
58
+ const form = new FormData()
59
+ const val = value instanceof Buffer ? value : (typeof value === 'string' ? value : JSON.stringify(value))
60
+ form.append('value', val)
61
+ form.append('metadata', JSON.stringify(opts.metadata))
62
+ const formHeaders = form.getHeaders()
63
+ Object.assign(headers, formHeaders)
64
+ const res = await axios.put(url, form, { headers })
65
+ if (res.status !== 200) {
66
+ throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
67
+ }
68
+ return
69
+ }
70
+
71
+ let data = value
72
+ if (typeof value === 'object' && !(value instanceof Buffer)) {
73
+ data = JSON.stringify(value)
74
+ headers['Content-Type'] = 'application/json'
75
+ }
76
+ else {
77
+ headers['Content-Type'] = 'text/plain'
78
+ }
79
+
80
+ const res = await axios.put(url, data, { headers })
81
+ if (res.status !== 200) {
82
+ throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
83
+ }
84
+ }
85
+
86
+ async function putJson(key, obj, opts = undefined) {
87
+ return put(key, JSON.stringify(obj), opts)
88
+ }
89
+
90
+ /**
91
+ * Convenience for writing only metadata on an index key.
92
+ * Stores a tiny value ('1') and attaches the real JSON to metadata.
93
+ */
94
+ async function putIndexMeta(key, metadata, opts = undefined) {
95
+ const meta = metadata && typeof metadata === 'object' ? metadata : {}
96
+ return put(key, '1', { ...(opts || {}), metadata: meta })
97
+ }
98
+
99
+ async function get(key, type = 'text') {
100
+ const url = `${base}/values/${encodeURIComponent(key)}`
101
+ try {
102
+ const res = await axios.get(url, {
103
+ headers: { Authorization: `Bearer ${apiKey}` },
104
+ responseType: 'text',
105
+ transformResponse: [x => x],
106
+ })
107
+ if (type === 'json') {
108
+ return parseJsonIfNeeded(res.data)
109
+ }
110
+ return res.data
111
+ }
112
+ catch (err) {
113
+ if (err?.response?.status === 404) {
114
+ return null
115
+ }
116
+ throw new Error(`KV get failed for ${key}: ${err.message}`)
117
+ }
118
+ }
119
+
120
+ async function del(key) {
121
+ const url = `${base}/values/${encodeURIComponent(key)}`
122
+ await Promise.allSettled([
123
+ axios.delete(url, { headers: { Authorization: `Bearer ${apiKey}` } }),
124
+ ])
125
+ }
126
+
127
+ /**
128
+ * List keys with optional prefix/limit/cursor.
129
+ * Returns { result, success, errors, messages } where result[i] has { name, expiration, metadata }.
130
+ */
131
+ async function listKeys({ prefix = '', limit = 1000, cursor = '' } = {}) {
132
+ const u = new URL(`${base}/keys`)
133
+ if (prefix) {
134
+ u.searchParams.set('prefix', prefix)
135
+ }
136
+ if (limit != null) {
137
+ u.searchParams.set('limit', String(limit))
138
+ }
139
+ if (cursor) {
140
+ u.searchParams.set('cursor', cursor)
141
+ }
142
+ const res = await axios.get(u.toString(), { headers: { Authorization: `Bearer ${apiKey}` } })
143
+ return res.data
144
+ }
145
+
146
+ module.exports = { put, putJson, putIndexMeta, get, del, listKeys }
@@ -0,0 +1,212 @@
1
+ // kvMirror.js
2
+ // Generic Firestore→Cloudflare KV mirroring helper.
3
+ // - Writes a canonical KV key per Firestore doc (value = serialized data).
4
+ // - Optionally writes index keys that carry JSON in **metadata** (value = '1').
5
+ // - Index-key metadata always includes { canonical: <canonicalKey> }.
6
+ // - Keeps a small manifest per canonical key to clean up all index keys on delete.
7
+
8
+ const { onDocumentWritten } = require('../config.js')
9
+ const kv = require('./kvClient')
10
+
11
+ function json(x) {
12
+ return JSON.stringify(x)
13
+ }
14
+
15
+ function slugIndexValue(value, maxLength = 80) {
16
+ return String(value ?? '')
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-+|-+$/g, '')
21
+ .slice(0, maxLength)
22
+ }
23
+
24
+ function setDiff(oldArr = [], newArr = []) {
25
+ const A = new Set(oldArr)
26
+ const B = new Set(newArr)
27
+ const toRemove = [...A].filter(x => !B.has(x))
28
+ const toAdd = [...B].filter(x => !A.has(x))
29
+ return { toRemove, toAdd }
30
+ }
31
+
32
+ /**
33
+ * createKvMirrorHandler({
34
+ * document: 'organizations/{orgId}/sites/{siteId}/published_posts/{postId}',
35
+ * makeCanonicalKey: (params, data) => `post:${params.orgId}:${params.siteId}:${params.postId}`,
36
+ * makeIndexKeys: (params, data) => [...], // optional
37
+ * makeMetadata: (data, params) => ({ title: data.title }), // optional, merged with { canonical }
38
+ * serialize: (data) => JSON.stringify(data), // optional
39
+ * timeoutSeconds: 180 // optional
40
+ * })
41
+ */
42
+ function createKvMirrorHandler({
43
+ document,
44
+ makeCanonicalKey,
45
+ makeIndexKeys,
46
+ makeMetadata,
47
+ serialize = json,
48
+ timeoutSeconds = 180,
49
+ }) {
50
+ return onDocumentWritten({ document, timeoutSeconds }, async (event) => {
51
+ const after = event.data?.after
52
+ const params = event.params || {}
53
+ const data = after?.exists ? after.data() : null
54
+
55
+ const canonicalKey = makeCanonicalKey(params, data)
56
+ const indexingEnabled = typeof makeIndexKeys === 'function'
57
+ const manifestKey = indexingEnabled ? `idx:manifest:${canonicalKey}` : null
58
+
59
+ if (!after?.exists) {
60
+ if (indexingEnabled) {
61
+ let prev = null
62
+ try {
63
+ prev = await kv.get(manifestKey, 'json')
64
+ }
65
+ catch (_) {
66
+ prev = null
67
+ }
68
+ const keys = Array.isArray(prev?.indexKeys) ? prev.indexKeys : []
69
+ const deletions = [
70
+ ...keys.map(k => kv.del(k)),
71
+ kv.del(canonicalKey),
72
+ kv.del(manifestKey),
73
+ ]
74
+ await Promise.allSettled(deletions)
75
+ }
76
+ else {
77
+ await kv.del(canonicalKey)
78
+ }
79
+ return
80
+ }
81
+
82
+ const baseMeta = { canonical: canonicalKey }
83
+ const customMetaCandidate = typeof makeMetadata === 'function' ? (makeMetadata(data, params) || null) : null
84
+ const metaValue = (customMetaCandidate && typeof customMetaCandidate === 'object')
85
+ ? { ...customMetaCandidate, canonical: canonicalKey }
86
+ : baseMeta
87
+
88
+ await kv.put(canonicalKey, serialize(data), { metadata: metaValue })
89
+
90
+ if (!indexingEnabled) {
91
+ return
92
+ }
93
+
94
+ const nextIndexKeys = (await Promise.resolve(makeIndexKeys(params, data)) || [])
95
+ .filter(Boolean)
96
+ .map(String)
97
+
98
+ let prev = null
99
+ try {
100
+ prev = await kv.get(manifestKey, 'json')
101
+ }
102
+ catch (_) {
103
+ prev = null
104
+ }
105
+
106
+ const oldIndexKeys = Array.isArray(prev?.indexKeys) ? prev.indexKeys : []
107
+ const { toRemove } = setDiff(oldIndexKeys, nextIndexKeys)
108
+
109
+ const upserts = nextIndexKeys.map(k => kv.putIndexMeta(k, metaValue))
110
+ await Promise.allSettled(upserts)
111
+
112
+ const removals = toRemove.map(k => kv.del(k))
113
+ await Promise.allSettled(removals)
114
+
115
+ await kv.put(manifestKey, { indexKeys: nextIndexKeys })
116
+ })
117
+ }
118
+
119
+ function createKvMirrorHandlerFromFields({
120
+ documentPath,
121
+ uniqueKey,
122
+ indexKeys = [],
123
+ metadataKeys = [],
124
+ serialize = json,
125
+ }) {
126
+ if (!uniqueKey || typeof uniqueKey !== 'string') {
127
+ throw new Error('createKvMirrorHandlerFromFields requires uniqueKey (e.g. "{orgId}:{siteId}")')
128
+ }
129
+ const docIdParam = 'docId'
130
+ const basePath = documentPath || ''
131
+ const document = basePath.includes(`{${docIdParam}}`) ? basePath : `${basePath}/{${docIdParam}}`
132
+ const collection = String(basePath || '')
133
+ .replace(new RegExp(`/{${docIdParam}}$`), '')
134
+ .split('/')
135
+ .filter(Boolean)
136
+ .pop()
137
+
138
+ const resolveUniqueKey = (params) => {
139
+ const template = String(uniqueKey || '').trim()
140
+ if (!template)
141
+ return ''
142
+ const tokens = template.match(/\{[^}]+\}/g) || []
143
+ let missing = false
144
+ const value = template.replace(/\{([^}]+)\}/g, (_, key) => {
145
+ const resolved = params?.[key]
146
+ if (resolved === undefined || resolved === null || resolved === '') {
147
+ missing = true
148
+ return ''
149
+ }
150
+ return String(resolved)
151
+ })
152
+ if (missing)
153
+ return ''
154
+ if (tokens.length === 0)
155
+ return value
156
+ return value
157
+ }
158
+
159
+ const makeCanonicalKey = (params) => {
160
+ const resolvedKey = resolveUniqueKey(params)
161
+ const docId = params?.[docIdParam]
162
+ if (!collection || !docId || !resolvedKey)
163
+ return ''
164
+ return `${collection}:${resolvedKey}:${docId}`
165
+ }
166
+
167
+ const makeIndexKeys = (params, data) => {
168
+ const docId = params?.[docIdParam]
169
+ const resolvedKey = resolveUniqueKey(params)
170
+
171
+ if (!collection || !docId || !resolvedKey)
172
+ return []
173
+
174
+ const keys = []
175
+ const fields = Array.isArray(indexKeys) ? indexKeys : []
176
+ for (const field of fields) {
177
+ if (!field || typeof field !== 'string')
178
+ continue
179
+ const rawValue = data?.[field]
180
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue]
181
+ for (const value of values) {
182
+ if (value === undefined || value === null || value === '')
183
+ continue
184
+ const slug = slugIndexValue(value)
185
+ if (!slug)
186
+ continue
187
+ keys.push(`idx:${collection}:${field}:${resolvedKey}:${slug}:${docId}`)
188
+ }
189
+ }
190
+ return keys
191
+ }
192
+
193
+ const makeMetadata = (data) => {
194
+ const meta = {}
195
+ const keys = Array.isArray(metadataKeys) ? metadataKeys : []
196
+ for (const key of keys) {
197
+ meta[key] = data?.[key] ?? ''
198
+ }
199
+ return meta
200
+ }
201
+
202
+ return createKvMirrorHandler({
203
+ document,
204
+ makeCanonicalKey,
205
+ makeIndexKeys: indexKeys.length ? makeIndexKeys : undefined,
206
+ makeMetadata: metadataKeys.length ? makeMetadata : undefined,
207
+ serialize,
208
+ timeoutSeconds: 180,
209
+ })
210
+ }
211
+
212
+ module.exports = { createKvMirrorHandler, createKvMirrorHandlerFromFields }
package/src/package.json CHANGED
@@ -1,35 +1,41 @@
1
1
  {
2
- "name": "functions",
3
- "type": "commonjs",
4
- "private": true,
5
- "description": "Cloud Functions for Firebase",
6
- "main": "index.js",
7
- "engines": {
8
- "node": "22"
9
- },
10
- "scripts": {
11
- "serve": "firebase emulators:start --only functions",
12
- "shell": "firebase functions:shell",
13
- "start": "npm run shell",
14
- "deploy": "firebase deploy --only functions",
15
- "logs": "firebase functions:log"
16
- },
17
- "dependencies": {
18
- "@google-cloud/pubsub": "^4.9.0",
19
- "aws-sdk": "^2.1692.0",
20
- "crypto": "^1.0.1",
21
- "dotenv": "^16.3.1",
22
- "exceljs": "^4.4.0",
23
- "firebase-admin": "^13.0.2",
24
- "firebase-functions": "^6.2.0",
25
- "form-data": "^4.0.0",
26
- "formidable-serverless": "^1.1.1",
27
- "moment-timezone": "^0.5.43",
28
- "openai": "^4.11.1",
29
- "stripe": "^13.8.0",
30
- "twilio": "^4.18.0"
31
- },
32
- "devDependencies": {
33
- "firebase-functions-test": "^3.4.0"
34
- }
35
- }
2
+ "name": "functions",
3
+ "type": "commonjs",
4
+ "private": true,
5
+ "description": "Cloud Functions for Firebase",
6
+ "main": "index.js",
7
+ "engines": {
8
+ "node": "22"
9
+ },
10
+ "scripts": {
11
+ "deploy": "firebase deploy --only functions",
12
+ "logs": "firebase functions:log",
13
+ "serve": "firebase emulators:start --only functions",
14
+ "shell": "firebase functions:shell",
15
+ "start": "npm run shell"
16
+ },
17
+ "dependencies": {
18
+ "@aws-sdk/client-s3": "^3.582.0",
19
+ "@aws-sdk/s3-request-presigner": "^3.582.0",
20
+ "@google-cloud/pubsub": "^4.11.0",
21
+ "@google-cloud/functions-framework": "^3.4.5",
22
+ "@napi-rs/canvas": "^0.1.0",
23
+ "aws-sdk": "^2.1693.0",
24
+ "axios": "^1.7.2",
25
+ "cloudconvert": "^2.3.7",
26
+ "crypto": "^1.0.1",
27
+ "dotenv": "^16.6.1",
28
+ "exceljs": "^4.4.0",
29
+ "firebase-admin": "^13.6.0",
30
+ "firebase-functions": "^6.6.0",
31
+ "form-data": "^4.0.5",
32
+ "formidable-serverless": "^1.1.1",
33
+ "moment-timezone": "^0.5.48",
34
+ "openai": "^4.104.0",
35
+ "stripe": "^13.11.0",
36
+ "twilio": "^4.23.0"
37
+ },
38
+ "devDependencies": {
39
+ "firebase-functions-test": "^0.2.0"
40
+ }
41
+ }
@@ -5,6 +5,63 @@ actual_dir=$(readlink -f $(dirname $0))
5
5
  # Get the project root directory by removing the node_modules directory path
6
6
  project_root=$(echo "$actual_dir" | awk '{gsub(/node_modules.*$/, ""); print}')
7
7
 
8
+ merge_env_file () {
9
+ local src="$1"
10
+ local dest="$2"
11
+
12
+ if [ ! -f "$src" ]; then
13
+ return
14
+ fi
15
+
16
+ if [ ! -f "$dest" ]; then
17
+ cp "$src" "$dest"
18
+ return
19
+ fi
20
+
21
+ [ "$(tail -c1 "$dest")" != "" ] && echo "" >> "$dest"
22
+
23
+ while IFS= read -r line; do
24
+ if [ -z "$line" ]; then
25
+ continue
26
+ fi
27
+ if echo "$line" | grep -qE '^[A-Za-z0-9_]+='; then
28
+ key="${line%%=*}"
29
+ if ! grep -qE "^${key}=" "$dest"; then
30
+ echo "$line" >> "$dest"
31
+ fi
32
+ fi
33
+ done < "$src"
34
+ }
35
+
36
+ merge_package_json () {
37
+ local src="$1"
38
+ local dest="$2"
39
+
40
+ if [ ! -f "$src" ] || [ ! -f "$dest" ]; then
41
+ return
42
+ fi
43
+
44
+ node - "$src" "$dest" <<'NODE'
45
+ const fs = require('fs')
46
+ const [,, srcPath, destPath] = process.argv
47
+ const src = JSON.parse(fs.readFileSync(srcPath, 'utf8'))
48
+ const dest = JSON.parse(fs.readFileSync(destPath, 'utf8'))
49
+ const sections = ['dependencies', 'devDependencies']
50
+
51
+ for (const section of sections) {
52
+ const srcDeps = src[section] || {}
53
+ if (!dest[section])
54
+ dest[section] = {}
55
+ for (const [name, version] of Object.entries(srcDeps)) {
56
+ if (!dest[section][name])
57
+ dest[section][name] = version
58
+ }
59
+ }
60
+
61
+ fs.writeFileSync(destPath, `${JSON.stringify(dest, null, 2)}\n`)
62
+ NODE
63
+ }
64
+
8
65
 
9
66
  # Check if the destination file exists
10
67
  if [ ! -f "$project_root/firestore.rules" ]; then
@@ -47,52 +104,32 @@ fi
47
104
 
48
105
  cp ./src/edgeFirebase.js "$project_root/functions/edgeFirebase.js"
49
106
  cp ./src/config.js "$project_root/functions/config.js"
107
+ cp ./src/cms.js "$project_root/functions/cms.js"
50
108
 
51
- if [ ! -f "$project_root/functions/index.js" ]; then
52
- cp ./src/index.js "$project_root/functions/index.js"
53
- fi
54
-
55
- if [ ! -f "$project_root/functions/.env.dev" ]; then
56
- cp ./src/.env.dev "$project_root/functions/.env.dev"
109
+ if [ ! -d "$project_root/functions/kv" ]; then
110
+ mkdir -p "$project_root/functions/kv"
57
111
  fi
58
112
 
59
- if [ ! -f "$project_root/functions/.env.prod" ]; then
60
- cp ./src/.env.prod "$project_root/functions/.env.prod"
61
- fi
113
+ cp ./src/kv/*.js "$project_root/functions/kv/"
62
114
 
63
- if [ ! -f "$project_root/.env.dev" ]; then
64
- cp ./src/.env.development "$project_root/.env.dev"
115
+ if [ ! -f "$project_root/functions/index.js" ]; then
116
+ cp ./src/index.js "$project_root/functions/index.js"
117
+ else
118
+ sed -i.backup '/\/\/ START @edge\/firebase functions/,/\/\/ END @edge\/firebase functions/d' "$project_root/functions/index.js"
119
+ [ "$(tail -c1 $project_root/functions/index.js)" != "" ] && echo "" >> "$project_root/functions/index.js"
120
+ awk '/\/\/ START @edge\/firebase functions/,/\/\/ END @edge\/firebase functions/' ./src/functions.js | \
121
+ sed '1d;$d' | \
122
+ sed -e '1s/^/\/\/ START @edge\/firebase functions\n/' -e '$s/$/\n\/\/ END @edge\/firebase functions/' \
123
+ >> "$project_root/functions/index.js";
65
124
  fi
66
125
 
67
- if [ ! -f "$project_root/.env" ]; then
68
- cp ./src/.env.production "$project_root/.env"
69
- fi
126
+ merge_env_file "./src/.env.dev" "$project_root/functions/.env.dev"
127
+ merge_env_file "./src/.env.prod" "$project_root/functions/.env.prod"
128
+ merge_env_file "./src/.env.development" "$project_root/.env.dev"
129
+ merge_env_file "./src/.env.production" "$project_root/.env"
70
130
 
71
131
  if [ ! -f "$project_root/functions/package.json" ]; then
72
132
  cp ./src/package.json "$project_root/functions/package.json"
73
- cd "$project_root/functions"
74
- npm install --no-audit --silent
75
- cd "$project_root"
133
+ else
134
+ merge_package_json "$actual_dir/package.json" "$project_root/functions/package.json"
76
135
  fi
77
-
78
- # Upgrade specific npm packages in the functions directory
79
- cd "$project_root/functions"
80
-
81
- # List of packages to upgrade
82
- npm install --save \
83
- "@google-cloud/pubsub@^4.9.0" \
84
- "aws-sdk@^2.1692.0" \
85
- "crypto@^1.0.1" \
86
- "dotenv@^16.3.1" \
87
- "exceljs@^4.4.0" \
88
- "firebase-admin@^13.0.2" \
89
- "firebase-functions@^6.2.0" \
90
- "form-data@^4.0.0" \
91
- "formidable-serverless@^1.1.1" \
92
- "moment-timezone@^0.5.43" \
93
- "openai@^4.11.1" \
94
- "stripe@^13.8.0" \
95
- "twilio@^4.18.0"
96
-
97
- # Return to the project root
98
- cd "$project_root"