@comapeo/core 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.
Files changed (186) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +31 -0
  3. package/dist/blob-api.d.ts +92 -0
  4. package/dist/blob-api.d.ts.map +1 -0
  5. package/dist/blob-store/index.d.ts +163 -0
  6. package/dist/blob-store/index.d.ts.map +1 -0
  7. package/dist/blob-store/live-download.d.ts +107 -0
  8. package/dist/blob-store/live-download.d.ts.map +1 -0
  9. package/dist/config-import.d.ts +74 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/constants.d.ts +14 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/core-manager/bitfield-rle.d.ts +25 -0
  14. package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
  15. package/dist/core-manager/core-index.d.ts +56 -0
  16. package/dist/core-manager/core-index.d.ts.map +1 -0
  17. package/dist/core-manager/index.d.ts +125 -0
  18. package/dist/core-manager/index.d.ts.map +1 -0
  19. package/dist/core-manager/random-access-file-pool.d.ts +17 -0
  20. package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
  21. package/dist/core-manager/remote-bitfield.d.ts +146 -0
  22. package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
  23. package/dist/core-ownership.d.ts +112 -0
  24. package/dist/core-ownership.d.ts.map +1 -0
  25. package/dist/datastore/index.d.ts +91 -0
  26. package/dist/datastore/index.d.ts.map +1 -0
  27. package/dist/datatype/index.d.ts +108 -0
  28. package/dist/discovery/local-discovery.d.ts +64 -0
  29. package/dist/discovery/local-discovery.d.ts.map +1 -0
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/fastify-controller.d.ts +27 -0
  33. package/dist/fastify-controller.d.ts.map +1 -0
  34. package/dist/fastify-plugins/blobs.d.ts +6 -0
  35. package/dist/fastify-plugins/blobs.d.ts.map +1 -0
  36. package/dist/fastify-plugins/constants.d.ts +3 -0
  37. package/dist/fastify-plugins/constants.d.ts.map +1 -0
  38. package/dist/fastify-plugins/icons.d.ts +6 -0
  39. package/dist/fastify-plugins/icons.d.ts.map +1 -0
  40. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  41. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  46. package/dist/fastify-plugins/utils.d.ts +23 -0
  47. package/dist/fastify-plugins/utils.d.ts.map +1 -0
  48. package/dist/generated/extensions.d.ts +44 -0
  49. package/dist/generated/extensions.d.ts.map +1 -0
  50. package/dist/generated/keys.d.ts +36 -0
  51. package/dist/generated/keys.d.ts.map +1 -0
  52. package/dist/generated/rpc.d.ts +87 -0
  53. package/dist/generated/rpc.d.ts.map +1 -0
  54. package/dist/icon-api.d.ts +109 -0
  55. package/dist/icon-api.d.ts.map +1 -0
  56. package/dist/index-writer/index.d.ts +51 -0
  57. package/dist/index-writer/index.d.ts.map +1 -0
  58. package/dist/index.d.ts +14 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/invite-api.d.ts +70 -0
  61. package/dist/invite-api.d.ts.map +1 -0
  62. package/dist/lib/hashmap.d.ts +62 -0
  63. package/dist/lib/hashmap.d.ts.map +1 -0
  64. package/dist/lib/hypercore-helpers.d.ts +6 -0
  65. package/dist/lib/hypercore-helpers.d.ts.map +1 -0
  66. package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
  67. package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
  68. package/dist/lib/ponyfills.d.ts +10 -0
  69. package/dist/lib/ponyfills.d.ts.map +1 -0
  70. package/dist/lib/string.d.ts +2 -0
  71. package/dist/lib/string.d.ts.map +1 -0
  72. package/dist/lib/timing-safe-equal.d.ts +15 -0
  73. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  74. package/dist/local-peers.d.ts +151 -0
  75. package/dist/local-peers.d.ts.map +1 -0
  76. package/dist/logger.d.ts +32 -0
  77. package/dist/logger.d.ts.map +1 -0
  78. package/dist/mapeo-manager.d.ts +178 -0
  79. package/dist/mapeo-manager.d.ts.map +1 -0
  80. package/dist/mapeo-project.d.ts +3233 -0
  81. package/dist/mapeo-project.d.ts.map +1 -0
  82. package/dist/member-api.d.ts +114 -0
  83. package/dist/member-api.d.ts.map +1 -0
  84. package/dist/roles.d.ts +157 -0
  85. package/dist/roles.d.ts.map +1 -0
  86. package/dist/schema/client.d.ts +284 -0
  87. package/dist/schema/client.d.ts.map +1 -0
  88. package/dist/schema/project.d.ts +1812 -0
  89. package/dist/schema/project.d.ts.map +1 -0
  90. package/dist/schema/schema-to-drizzle.d.ts +20 -0
  91. package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
  92. package/dist/schema/types.d.ts +98 -0
  93. package/dist/schema/types.d.ts.map +1 -0
  94. package/dist/schema/utils.d.ts +55 -0
  95. package/dist/schema/utils.d.ts.map +1 -0
  96. package/dist/sync/core-sync-state.d.ts +252 -0
  97. package/dist/sync/core-sync-state.d.ts.map +1 -0
  98. package/dist/sync/namespace-sync-state.d.ts +47 -0
  99. package/dist/sync/namespace-sync-state.d.ts.map +1 -0
  100. package/dist/sync/peer-sync-controller.d.ts +44 -0
  101. package/dist/sync/peer-sync-controller.d.ts.map +1 -0
  102. package/dist/sync/sync-api.d.ts +158 -0
  103. package/dist/sync/sync-api.d.ts.map +1 -0
  104. package/dist/sync/sync-state.d.ts +40 -0
  105. package/dist/sync/sync-state.d.ts.map +1 -0
  106. package/dist/translation-api.d.ts +288 -0
  107. package/dist/translation-api.d.ts.map +1 -0
  108. package/dist/types.d.ts +115 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/utils.d.ts +115 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils_types.d.ts +14 -0
  113. package/drizzle/client/0000_bumpy_carnage.sql +33 -0
  114. package/drizzle/client/meta/0000_snapshot.json +199 -0
  115. package/drizzle/client/meta/_journal.json +13 -0
  116. package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
  117. package/drizzle/project/meta/0000_snapshot.json +1137 -0
  118. package/drizzle/project/meta/_journal.json +13 -0
  119. package/package.json +202 -0
  120. package/src/blob-api.js +139 -0
  121. package/src/blob-store/index.js +325 -0
  122. package/src/blob-store/live-download.js +373 -0
  123. package/src/config-import.js +604 -0
  124. package/src/constants.js +34 -0
  125. package/src/core-manager/bitfield-rle.js +235 -0
  126. package/src/core-manager/core-index.js +87 -0
  127. package/src/core-manager/index.js +504 -0
  128. package/src/core-manager/random-access-file-pool.js +30 -0
  129. package/src/core-manager/remote-bitfield.js +416 -0
  130. package/src/core-ownership.js +235 -0
  131. package/src/datastore/README.md +46 -0
  132. package/src/datastore/index.js +234 -0
  133. package/src/datatype/README.md +33 -0
  134. package/src/datatype/index.d.ts +108 -0
  135. package/src/datatype/index.js +358 -0
  136. package/src/discovery/local-discovery.js +303 -0
  137. package/src/errors.js +5 -0
  138. package/src/fastify-controller.js +84 -0
  139. package/src/fastify-plugins/blobs.js +139 -0
  140. package/src/fastify-plugins/constants.js +5 -0
  141. package/src/fastify-plugins/icons.js +158 -0
  142. package/src/fastify-plugins/maps/index.js +173 -0
  143. package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
  144. package/src/fastify-plugins/maps/static-maps.js +271 -0
  145. package/src/fastify-plugins/utils.js +52 -0
  146. package/src/generated/README.md +3 -0
  147. package/src/generated/extensions.d.ts +44 -0
  148. package/src/generated/extensions.js +196 -0
  149. package/src/generated/extensions.ts +237 -0
  150. package/src/generated/keys.d.ts +36 -0
  151. package/src/generated/keys.js +148 -0
  152. package/src/generated/keys.ts +185 -0
  153. package/src/generated/rpc.d.ts +87 -0
  154. package/src/generated/rpc.js +389 -0
  155. package/src/generated/rpc.ts +463 -0
  156. package/src/icon-api.js +282 -0
  157. package/src/index-writer/README.md +38 -0
  158. package/src/index-writer/index.js +124 -0
  159. package/src/index.js +16 -0
  160. package/src/invite-api.js +450 -0
  161. package/src/lib/hashmap.js +91 -0
  162. package/src/lib/hypercore-helpers.js +18 -0
  163. package/src/lib/noise-secret-stream-helpers.js +37 -0
  164. package/src/lib/ponyfills.js +25 -0
  165. package/src/lib/string.js +7 -0
  166. package/src/lib/timing-safe-equal.js +34 -0
  167. package/src/local-peers.js +737 -0
  168. package/src/logger.js +99 -0
  169. package/src/mapeo-manager.js +914 -0
  170. package/src/mapeo-project.js +980 -0
  171. package/src/member-api.js +319 -0
  172. package/src/roles.js +412 -0
  173. package/src/schema/client.js +55 -0
  174. package/src/schema/project.js +44 -0
  175. package/src/schema/schema-to-drizzle.js +118 -0
  176. package/src/schema/types.ts +153 -0
  177. package/src/schema/utils.js +51 -0
  178. package/src/sync/core-sync-state.js +440 -0
  179. package/src/sync/namespace-sync-state.js +193 -0
  180. package/src/sync/peer-sync-controller.js +332 -0
  181. package/src/sync/sync-api.js +588 -0
  182. package/src/sync/sync-state.js +63 -0
  183. package/src/translation-api.js +141 -0
  184. package/src/types.ts +149 -0
  185. package/src/utils.js +210 -0
  186. package/src/utils_types.d.ts +14 -0
@@ -0,0 +1,84 @@
1
+ import { promisify } from 'util'
2
+ import StateMachine from 'start-stop-state-machine'
3
+
4
+ /**
5
+ * @typedef {Object} StartOpts
6
+ *
7
+ * @property {string} [host]
8
+ * @property {number} [port]
9
+ */
10
+
11
+ // Class to properly manage the server lifecycle of a Fastify instance
12
+ export class FastifyController {
13
+ #fastify
14
+ #fastifyStarted
15
+ #serverState
16
+
17
+ /**
18
+ * @param {Object} opts
19
+ * @param {import('fastify').FastifyInstance} opts.fastify
20
+ */
21
+ constructor({ fastify }) {
22
+ this.#fastifyStarted = false
23
+
24
+ this.#fastify = fastify
25
+
26
+ this.#serverState = new StateMachine({
27
+ start: this.#startServer.bind(this),
28
+ stop: this.#stopServer.bind(this),
29
+ })
30
+ }
31
+
32
+ /**
33
+ * @param {StartOpts} [opts]
34
+ */
35
+ async #startServer({ host = '127.0.0.1', port = 0 } = {}) {
36
+ if (!this.#fastifyStarted) {
37
+ this.#fastifyStarted = true
38
+ await this.#fastify.listen({ host, port })
39
+ return
40
+ }
41
+
42
+ const { server } = this.#fastify
43
+
44
+ await new Promise((res, rej) => {
45
+ server.listen.call(server, { host, port })
46
+
47
+ server.once('listening', onListening)
48
+ server.once('error', onError)
49
+
50
+ function onListening() {
51
+ server.removeListener('error', onError)
52
+ res(null)
53
+ }
54
+
55
+ /**
56
+ * @param {Error} err
57
+ */
58
+ function onError(err) {
59
+ server.removeListener('listening', onListening)
60
+ rej(err)
61
+ }
62
+ })
63
+ }
64
+
65
+ async #stopServer() {
66
+ const { server } = this.#fastify
67
+ await promisify(server.close.bind(server))()
68
+ }
69
+
70
+ /**
71
+ * @param {StartOpts} [opts]
72
+ */
73
+ async start(opts) {
74
+ await this.#serverState.start(opts)
75
+ }
76
+
77
+ async started() {
78
+ return this.#serverState.started()
79
+ }
80
+
81
+ async stop() {
82
+ await this.#serverState.stop()
83
+ }
84
+ }
@@ -0,0 +1,139 @@
1
+ import fp from 'fastify-plugin'
2
+ import { filetypemime } from 'magic-bytes.js'
3
+ import { Type as T } from '@sinclair/typebox'
4
+
5
+ import { SUPPORTED_BLOB_VARIANTS } from '../blob-store/index.js'
6
+ import { HEX_REGEX_32_BYTES, Z_BASE_32_REGEX_32_BYTES } from './constants.js'
7
+
8
+ /** @import { BlobId } from '../types.js' */
9
+
10
+ export default fp(blobServerPlugin, {
11
+ fastify: '4.x',
12
+ name: 'mapeo-blobs',
13
+ })
14
+
15
+ /**
16
+ * @typedef {Object} BlobServerPluginOpts
17
+ *
18
+ * @property {(projectPublicId: string) => Promise<import('../blob-store/index.js').BlobStore>} getBlobStore
19
+ */
20
+
21
+ const BLOB_TYPES = /** @type {BlobId['type'][]} */ (
22
+ Object.keys(SUPPORTED_BLOB_VARIANTS)
23
+ )
24
+ const BLOB_VARIANTS = [
25
+ ...new Set(Object.values(SUPPORTED_BLOB_VARIANTS).flat()),
26
+ ]
27
+
28
+ const PARAMS_JSON_SCHEMA = T.Object({
29
+ projectPublicId: T.String({ pattern: Z_BASE_32_REGEX_32_BYTES }),
30
+ driveId: T.String({ pattern: HEX_REGEX_32_BYTES }),
31
+ type: T.Union(
32
+ BLOB_TYPES.map((type) => {
33
+ return T.Literal(type)
34
+ })
35
+ ),
36
+ variant: T.Union(
37
+ BLOB_VARIANTS.map((variant) => {
38
+ return T.Literal(variant)
39
+ })
40
+ ),
41
+ name: T.String(),
42
+ })
43
+
44
+ /** @type {import('fastify').FastifyPluginAsync<import('fastify').RegisterOptions & BlobServerPluginOpts>} */
45
+ async function blobServerPlugin(fastify, options) {
46
+ if (!options.getBlobStore) throw new Error('Missing getBlobStore')
47
+
48
+ // We call register here so that the `prefix` option can work if desired
49
+ // https://fastify.dev/docs/latest/Reference/Routes#route-prefixing-and-fastify-plugin
50
+ fastify.register(routes, options)
51
+ }
52
+
53
+ /** @type {import('fastify').FastifyPluginAsync<Omit<BlobServerPluginOpts, 'prefix'>, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
54
+ async function routes(fastify, options) {
55
+ const { getBlobStore } = options
56
+
57
+ fastify.get(
58
+ '/:projectPublicId/:driveId/:type/:variant/:name',
59
+ { schema: { params: PARAMS_JSON_SCHEMA } },
60
+ async (request, reply) => {
61
+ const { projectPublicId, ...blobId } = request.params
62
+
63
+ if (!isValidBlobId(blobId)) {
64
+ reply.code(400)
65
+ throw new Error(
66
+ `Unsupported variant "${blobId.variant}" for ${blobId.type}`
67
+ )
68
+ }
69
+ const { driveId } = blobId
70
+
71
+ let blobStore
72
+ try {
73
+ blobStore = await getBlobStore(projectPublicId)
74
+ } catch (e) {
75
+ reply.code(404)
76
+ throw e
77
+ }
78
+
79
+ let entry
80
+ try {
81
+ entry = await blobStore.entry(blobId, { wait: false })
82
+ } catch (e) {
83
+ reply.code(404)
84
+ throw e
85
+ }
86
+
87
+ if (!entry) {
88
+ reply.code(404)
89
+ throw new Error('Entry not found')
90
+ }
91
+
92
+ const { metadata } = entry.value
93
+
94
+ let blobStream
95
+ try {
96
+ blobStream = await blobStore.createEntryReadStream(driveId, entry)
97
+ } catch (e) {
98
+ reply.code(404)
99
+ throw e
100
+ }
101
+
102
+ // Extract the 'mimeType' property of the metadata and use it for the response header if found
103
+ if (
104
+ metadata &&
105
+ 'mimeType' in metadata &&
106
+ typeof metadata.mimeType === 'string'
107
+ ) {
108
+ reply.header('Content-Type', metadata.mimeType)
109
+ } else {
110
+ // Attempt to guess the MIME type based on the blob contents
111
+ const blobSlice = await blobStore.getEntryBlob(driveId, entry, {
112
+ length: 20,
113
+ })
114
+
115
+ if (!blobSlice) {
116
+ reply.code(404)
117
+ throw new Error('Blob not found')
118
+ }
119
+
120
+ const [guessedMime] = filetypemime(blobSlice)
121
+
122
+ reply.header('Content-Type', guessedMime || 'application/octet-stream')
123
+ }
124
+
125
+ return reply.send(blobStream)
126
+ }
127
+ )
128
+ }
129
+
130
+ /**
131
+ * @param {Omit<BlobId, 'variant'> & { variant: BlobId['variant'] }} maybeBlobId
132
+ * @returns {maybeBlobId is BlobId}
133
+ */
134
+ function isValidBlobId(maybeBlobId) {
135
+ const { type, variant } = maybeBlobId
136
+ /** @type {readonly BlobId['variant'][]} */
137
+ const validVariants = SUPPORTED_BLOB_VARIANTS[type]
138
+ return validVariants.includes(variant)
139
+ }
@@ -0,0 +1,5 @@
1
+ // hex encoded 32-byte string
2
+ export const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$'
3
+
4
+ // z-base-32 encoded 32-byte string (52 characters)
5
+ export const Z_BASE_32_REGEX_32_BYTES = '^[0-9a-zA-Z]{52}$'
@@ -0,0 +1,158 @@
1
+ import { Type as T } from '@sinclair/typebox'
2
+ import fp from 'fastify-plugin'
3
+ import { docSchemas } from '@comapeo/schema'
4
+
5
+ import { kGetIconBlob } from '../icon-api.js'
6
+ import { HEX_REGEX_32_BYTES, Z_BASE_32_REGEX_32_BYTES } from './constants.js'
7
+ import { ExhaustivenessError } from '../utils.js'
8
+
9
+ export default fp(iconServerPlugin, {
10
+ fastify: '4.x',
11
+ name: 'mapeo-icons',
12
+ })
13
+
14
+ const ICON_DOC_ID_STRING = T.String({ pattern: HEX_REGEX_32_BYTES })
15
+ const PROJECT_PUBLIC_ID_STRING = T.String({ pattern: Z_BASE_32_REGEX_32_BYTES })
16
+
17
+ const VALID_SIZES = docSchemas.icon.definitions.size.enum
18
+ const VALID_MIME_TYPES = docSchemas.icon.properties.variants.items.oneOf.map(
19
+ (iconType) => iconType.properties.mimeType.const
20
+ )
21
+ const VALID_PIXEL_DENSITIES = docSchemas.icon.properties.variants.items.oneOf
22
+ .map((iconType) =>
23
+ 'pixelDensity' in iconType.properties
24
+ ? iconType.properties.pixelDensity.enum
25
+ : []
26
+ )
27
+ .flat()
28
+
29
+ const PARAMS_JSON_SCHEMA = T.Object({
30
+ iconDocId: ICON_DOC_ID_STRING,
31
+ projectPublicId: PROJECT_PUBLIC_ID_STRING,
32
+ iconInfo: T.String({
33
+ pattern: `^(${VALID_SIZES.join('|')})(@(${VALID_PIXEL_DENSITIES.join(
34
+ '|'
35
+ )}+)x)?$`,
36
+ }),
37
+ mimeTypeExtension: T.Union(
38
+ VALID_MIME_TYPES.map((mimeType) => {
39
+ switch (mimeType) {
40
+ case 'image/png':
41
+ return T.Literal('png')
42
+ case 'image/svg+xml':
43
+ return T.Literal('svg')
44
+ default:
45
+ throw new ExhaustivenessError(mimeType)
46
+ }
47
+ })
48
+ ),
49
+ })
50
+
51
+ /**
52
+ * @typedef {Object} IconServerPluginOpts
53
+ *
54
+ * @property {(projectId: string) => Promise<import('../mapeo-project.js').MapeoProject>} getProject
55
+ **/
56
+
57
+ /** @type {import('fastify').FastifyPluginAsync<import('fastify').RegisterOptions & IconServerPluginOpts>} */
58
+ async function iconServerPlugin(fastify, options) {
59
+ if (!options.getProject) throw new Error('Missing getProject')
60
+ fastify.register(routes, options)
61
+ }
62
+
63
+ /** @type {import('fastify').FastifyPluginAsync<Omit<IconServerPluginOpts, 'prefix'>, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
64
+ async function routes(fastify, options) {
65
+ const { getProject } = options
66
+
67
+ fastify.get(
68
+ '/:projectPublicId/:iconDocId/:iconInfo.:mimeTypeExtension',
69
+ { schema: { params: PARAMS_JSON_SCHEMA } },
70
+ async (req, res) => {
71
+ const { projectPublicId, iconDocId, iconInfo, mimeTypeExtension } =
72
+ req.params
73
+
74
+ const { size, pixelDensity } = extractSizeAndPixelDensity(iconInfo)
75
+
76
+ const project = await getProject(projectPublicId)
77
+
78
+ const mimeType =
79
+ mimeTypeExtension === 'png' ? 'image/png' : 'image/svg+xml'
80
+
81
+ try {
82
+ const icon = await project.$icons[kGetIconBlob](
83
+ iconDocId,
84
+ mimeType === 'image/svg+xml'
85
+ ? {
86
+ size,
87
+ mimeType,
88
+ }
89
+ : {
90
+ size,
91
+ pixelDensity,
92
+ mimeType,
93
+ }
94
+ )
95
+
96
+ res.header('Content-Type', mimeType)
97
+ return res.send(icon)
98
+ } catch (err) {
99
+ res.code(404)
100
+ throw err
101
+ }
102
+ }
103
+ )
104
+ }
105
+
106
+ // matches strings that end in `@_x` and captures `_`, where `_` is a positive integer
107
+ const DENSITY_MATCH_REGEX = /@(\d+)x$/i
108
+
109
+ /**
110
+ * @param {string} input
111
+ *
112
+ * @return {{
113
+ * pixelDensity: import('../icon-api.js').BitmapOpts['pixelDensity'],
114
+ * size: import('../icon-api.js').ValidSizes}}
115
+ */
116
+ function extractSizeAndPixelDensity(input) {
117
+ const result = DENSITY_MATCH_REGEX.exec(input)
118
+
119
+ if (result) {
120
+ const [match, capturedDensity] = result
121
+ const size = input.split(match, 1)[0]
122
+ const pixelDensity = parseInt(capturedDensity, 10)
123
+
124
+ assertValidSize(size)
125
+ assertValidPixelDensity(pixelDensity)
126
+
127
+ return { size, pixelDensity }
128
+ }
129
+
130
+ assertValidSize(input)
131
+
132
+ return { size: input, pixelDensity: 1 }
133
+ }
134
+
135
+ /**
136
+ * @param {string} value
137
+ * @returns {asserts value is Exclude<import('@comapeo/schema').Icon['variants'][number]['size'],'size_unspecified'>}
138
+ */
139
+ function assertValidSize(value) {
140
+ if (
141
+ !VALID_SIZES.includes(
142
+ // @ts-expect-error
143
+ value
144
+ )
145
+ ) {
146
+ throw new Error(`'${value}' is not a valid icon size`)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @param {unknown} value
152
+ * @returns {asserts value is import('../icon-api.js').BitmapOpts['pixelDensity']}
153
+ */
154
+ function assertValidPixelDensity(value) {
155
+ if (!VALID_PIXEL_DENSITIES.includes(/** @type {any} */ (value))) {
156
+ throw new Error(`${value} is not a valid icon pixel density`)
157
+ }
158
+ }
@@ -0,0 +1,173 @@
1
+ import fp from 'fastify-plugin'
2
+ import { Type as T } from '@sinclair/typebox'
3
+ import { fetch } from 'undici'
4
+
5
+ import {
6
+ NotFoundError,
7
+ createStyleJsonResponseHeaders,
8
+ getFastifyServerAddress,
9
+ } from '../utils.js'
10
+ import { PLUGIN_NAME as MAPEO_STATIC_MAPS } from './static-maps.js'
11
+ import { PLUGIN_NAME as MAPEO_OFFLINE_FALLBACK } from './offline-fallback-map.js'
12
+
13
+ export const PLUGIN_NAME = 'mapeo-maps'
14
+ export const DEFAULT_MAPBOX_STYLE_URL =
15
+ 'https://api.mapbox.com/styles/v1/mapbox/outdoors-v12'
16
+
17
+ const MAP_PROVIDER_API_KEY_QUERY_PARAM_BY_HOSTNAME = new Map([
18
+ // Mapbox expects `access_token`: https://docs.mapbox.com/api/maps/styles/
19
+ ['api.mapbox.com', 'access_token'],
20
+ // Protomaps expects `key` (no docs link yet)
21
+ ['api.protomaps.com', 'key'],
22
+ // MapTiler expects `key`: https://docs.maptiler.com/cloud/api/maps/
23
+ ['api.maptiler.com', 'key'],
24
+ // Stadia expects `api_key`: https://docs.stadiamaps.com/themes/
25
+ ['tiles.stadiamaps.com', 'api_key'],
26
+ // ArcGIS expects `token`: https://developers.arcgis.com/documentation/mapping-apis-and-services/security/api-keys/
27
+ ['basemapstyles-api.arcgis.com', 'token'],
28
+ ])
29
+
30
+ export const plugin = fp(mapsPlugin, {
31
+ fastify: '4.x',
32
+ name: PLUGIN_NAME,
33
+ decorators: { fastify: ['mapeoStaticMaps', 'mapeoFallbackMap'] },
34
+ dependencies: [MAPEO_STATIC_MAPS, MAPEO_OFFLINE_FALLBACK],
35
+ })
36
+
37
+ /**
38
+ * @typedef {object} MapsPluginOpts
39
+ * @property {string} [prefix]
40
+ * @property {string} [defaultOnlineStyleUrl]
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} MapsPluginContext
45
+ * @property {() => Promise<string>} getStyleJsonUrl
46
+ */
47
+
48
+ /** @type {import('fastify').FastifyPluginAsync<MapsPluginOpts>} */
49
+ async function mapsPlugin(fastify, opts) {
50
+ fastify.decorate('mapeoMaps', {
51
+ async getStyleJsonUrl() {
52
+ const base = await getFastifyServerAddress(fastify.server, {
53
+ timeout: 5000,
54
+ })
55
+
56
+ return new URL(`${opts.prefix || ''}/style.json`, base).href
57
+ },
58
+ })
59
+ fastify.register(routes, {
60
+ prefix: opts.prefix,
61
+ defaultOnlineStyleUrl: opts.defaultOnlineStyleUrl,
62
+ })
63
+ }
64
+
65
+ const GetStyleJsonQueryStringSchema = T.Object({
66
+ key: T.Optional(T.String()),
67
+ })
68
+
69
+ /** @type {import('fastify').FastifyPluginAsync<MapsPluginOpts, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
70
+ async function routes(fastify, opts) {
71
+ const { defaultOnlineStyleUrl = DEFAULT_MAPBOX_STYLE_URL } = opts
72
+
73
+ fastify.get(
74
+ '/style.json',
75
+ { schema: { querystring: GetStyleJsonQueryStringSchema } },
76
+ async (req, rep) => {
77
+ const serverAddress = await getFastifyServerAddress(req.server.server)
78
+
79
+ // 1. Attempt to get "default" local static map's style.json
80
+ {
81
+ const styleId = 'default'
82
+
83
+ const results = await Promise.all([
84
+ fastify.mapeoStaticMaps.getStyleJsonStats(styleId),
85
+ fastify.mapeoStaticMaps.getResolvedStyleJson(styleId, serverAddress),
86
+ ]).catch(() => {
87
+ fastify.log.warn('Cannot read default static map')
88
+ return null
89
+ })
90
+
91
+ if (results) {
92
+ const [stats, styleJson] = results
93
+ rep.headers(createStyleJsonResponseHeaders(stats.mtime))
94
+ return styleJson
95
+ }
96
+ }
97
+
98
+ // 2. Attempt to get a default style.json from online source
99
+ {
100
+ const { key } = req.query
101
+
102
+ const upstreamUrlObj = new URL(defaultOnlineStyleUrl)
103
+ const { hostname } = upstreamUrlObj
104
+
105
+ if (key) {
106
+ const paramToUpsert =
107
+ MAP_PROVIDER_API_KEY_QUERY_PARAM_BY_HOSTNAME.get(hostname)
108
+
109
+ if (paramToUpsert) {
110
+ // Note that even if the search param of interest already exists in the url
111
+ // it is overwritten by the key provided in the request's search params
112
+ upstreamUrlObj.searchParams.set(paramToUpsert, key)
113
+ } else {
114
+ fastify.log.warn(
115
+ `Provided API key will not be applied to unrecognized provider: ${hostname}`
116
+ )
117
+ }
118
+ }
119
+
120
+ try {
121
+ const upstreamResponse = await fetch(upstreamUrlObj.href, {
122
+ signal: AbortSignal.timeout(30_000),
123
+ })
124
+
125
+ if (upstreamResponse.ok) {
126
+ // Set up headers to forward
127
+ for (const [name, value] of upstreamResponse.headers) {
128
+ // Only forward headers related to caching
129
+ // https://www.rfc-editor.org/rfc/rfc9111#name-field-definitions
130
+ // e.g. usage from map renderer: https://github.com/maplibre/maplibre-gl-js/blob/26a7a6c2c142ef2e26db89f5fdf2338769494902/src/util/ajax.ts#L205
131
+ if (
132
+ ['age', 'cache-control', 'expires'].includes(name.toLowerCase())
133
+ ) {
134
+ rep.header(name, value)
135
+ }
136
+ }
137
+ // Some upstream providers will not set the 'application/json' content-type header despite the body being JSON e.g. Protomaps
138
+ // TODO: Should we forward the upstream 'content-type' header?
139
+ // We kind of assume that a Style Spec-compatible JSON payload will always be used by a provider
140
+ // Technically, there could be cases where a provider doesn't use the Mapbox Style Spec and has their own format,
141
+ // which may be delivered as some other content type
142
+ rep.header('content-type', 'application/json; charset=utf-8')
143
+ return upstreamResponse.json()
144
+ } else {
145
+ fastify.log.warn(
146
+ `Upstream style.json request returned non-2xx status: ${upstreamResponse.status} ${upstreamResponse.statusText}`
147
+ )
148
+ }
149
+ } catch (err) {
150
+ fastify.log.warn('Failed to make upstream style.json request', err)
151
+ }
152
+ }
153
+
154
+ // 3. Provide offline fallback map's style.json
155
+ {
156
+ let results = null
157
+
158
+ try {
159
+ results = await Promise.all([
160
+ fastify.mapeoFallbackMap.getStyleJsonStats(),
161
+ fastify.mapeoFallbackMap.getResolvedStyleJson(serverAddress),
162
+ ])
163
+ } catch (err) {
164
+ throw new NotFoundError(`id = fallback, style.json`)
165
+ }
166
+
167
+ const [stats, styleJson] = results
168
+ rep.headers(createStyleJsonResponseHeaders(stats.mtime))
169
+ return styleJson
170
+ }
171
+ }
172
+ )
173
+ }
@@ -0,0 +1,114 @@
1
+ import path from 'path'
2
+ import fs from 'fs/promises'
3
+ import FastifyStatic from '@fastify/static'
4
+ import fp from 'fastify-plugin'
5
+
6
+ import {
7
+ NotFoundError,
8
+ createStyleJsonResponseHeaders,
9
+ getFastifyServerAddress,
10
+ } from '../utils.js'
11
+
12
+ export const PLUGIN_NAME = 'mapeo-static-maps'
13
+
14
+ export const plugin = fp(offlineFallbackMapPlugin, {
15
+ fastify: '4.x',
16
+ name: PLUGIN_NAME,
17
+ })
18
+
19
+ /**
20
+ * @typedef {object} OfflineFallbackMapPluginOpts
21
+ * @property {string} [prefix]
22
+ * @property {string} styleJsonPath
23
+ * @property {string} sourcesDir
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} FallbackMapPluginDecorator
28
+ * @property {(serverAddress: string) => Promise<any>} getResolvedStyleJson
29
+ * @property {() => Promise<import('node:fs').Stats>} getStyleJsonStats
30
+ */
31
+
32
+ /** @type {import('fastify').FastifyPluginAsync<OfflineFallbackMapPluginOpts>} */
33
+ async function offlineFallbackMapPlugin(fastify, opts) {
34
+ const { styleJsonPath, sourcesDir } = opts
35
+
36
+ fastify.decorate(
37
+ 'mapeoFallbackMap',
38
+ /** @type {FallbackMapPluginDecorator} */
39
+ ({
40
+ async getResolvedStyleJson(serverAddress) {
41
+ const rawStyleJson = await fs.readFile(styleJsonPath, 'utf-8')
42
+ const styleJson = JSON.parse(rawStyleJson)
43
+
44
+ const sources = styleJson.sources || {}
45
+
46
+ const sourcesDirFiles = await fs.readdir(sourcesDir, {
47
+ withFileTypes: true,
48
+ })
49
+
50
+ for (const file of sourcesDirFiles) {
51
+ if (!file.isFile()) continue
52
+
53
+ if (file.name === 'style.json') continue
54
+
55
+ const extension = path.extname(file.name).toLowerCase()
56
+ if (!(extension === '.json' || extension === '.geojson')) continue
57
+
58
+ const sourceName = path.basename(file.name, extension) + '-source'
59
+
60
+ sources[sourceName] = {
61
+ type: 'geojson',
62
+ data: new URL(`${opts.prefix || ''}/${file.name}`, serverAddress)
63
+ .href,
64
+ }
65
+ }
66
+
67
+ styleJson.sources = sources
68
+
69
+ return styleJson
70
+ },
71
+ getStyleJsonStats() {
72
+ return fs.stat(styleJsonPath)
73
+ },
74
+ })
75
+ )
76
+
77
+ fastify.register(routes, {
78
+ prefix: opts.prefix,
79
+ styleJsonPath: opts.styleJsonPath,
80
+ sourcesDir: opts.sourcesDir,
81
+ })
82
+ }
83
+
84
+ /** @type {import('fastify').FastifyPluginAsync<OfflineFallbackMapPluginOpts, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
85
+ async function routes(fastify, opts) {
86
+ const { sourcesDir } = opts
87
+
88
+ fastify.register(FastifyStatic, {
89
+ root: sourcesDir,
90
+ decorateReply: false,
91
+ })
92
+
93
+ fastify.get('/style.json', async (req, rep) => {
94
+ const serverAddress = await getFastifyServerAddress(req.server.server)
95
+
96
+ let stats, styleJson
97
+
98
+ try {
99
+ const results = await Promise.all([
100
+ fastify.mapeoFallbackMap.getStyleJsonStats(),
101
+ fastify.mapeoFallbackMap.getResolvedStyleJson(serverAddress),
102
+ ])
103
+
104
+ stats = results[0]
105
+ styleJson = results[1]
106
+ } catch (err) {
107
+ throw new NotFoundError(`id = fallback, style.json`)
108
+ }
109
+
110
+ rep.headers(createStyleJsonResponseHeaders(stats.mtime))
111
+
112
+ return styleJson
113
+ })
114
+ }