@edgedev/firebase 2.1.78 → 2.2.79

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
+ }
@@ -47,9 +47,23 @@ fi
47
47
 
48
48
  cp ./src/edgeFirebase.js "$project_root/functions/edgeFirebase.js"
49
49
  cp ./src/config.js "$project_root/functions/config.js"
50
+ cp ./src/cms.js "$project_root/functions/cms.js"
51
+
52
+ if [ ! -d "$project_root/functions/kv" ]; then
53
+ mkdir -p "$project_root/functions/kv"
54
+ fi
55
+
56
+ cp ./src/kv/*.js "$project_root/functions/kv/"
50
57
 
51
58
  if [ ! -f "$project_root/functions/index.js" ]; then
52
59
  cp ./src/index.js "$project_root/functions/index.js"
60
+ else
61
+ sed -i.backup '/\/\/ START @edge\/firebase functions/,/\/\/ END @edge\/firebase functions/d' "$project_root/functions/index.js"
62
+ [ "$(tail -c1 $project_root/functions/index.js)" != "" ] && echo "" >> "$project_root/functions/index.js"
63
+ awk '/\/\/ START @edge\/firebase functions/,/\/\/ END @edge\/firebase functions/' ./src/functions.js | \
64
+ sed '1d;$d' | \
65
+ sed -e '1s/^/\/\/ START @edge\/firebase functions\n/' -e '$s/$/\n\/\/ END @edge\/firebase functions/' \
66
+ >> "$project_root/functions/index.js";
53
67
  fi
54
68
 
55
69
  if [ ! -f "$project_root/functions/.env.dev" ]; then
@@ -70,29 +84,4 @@ fi
70
84
 
71
85
  if [ ! -f "$project_root/functions/package.json" ]; then
72
86
  cp ./src/package.json "$project_root/functions/package.json"
73
- cd "$project_root/functions"
74
- npm install --no-audit --silent
75
- cd "$project_root"
76
87
  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"