@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.
- package/LICENSE.md +9 -0
- package/README.md +31 -0
- package/dist/blob-api.d.ts +92 -0
- package/dist/blob-api.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +163 -0
- package/dist/blob-store/index.d.ts.map +1 -0
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/config-import.d.ts +74 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core-manager/bitfield-rle.d.ts +25 -0
- package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
- package/dist/core-manager/core-index.d.ts +56 -0
- package/dist/core-manager/core-index.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +125 -0
- package/dist/core-manager/index.d.ts.map +1 -0
- package/dist/core-manager/random-access-file-pool.d.ts +17 -0
- package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
- package/dist/core-manager/remote-bitfield.d.ts +146 -0
- package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
- package/dist/core-ownership.d.ts +112 -0
- package/dist/core-ownership.d.ts.map +1 -0
- package/dist/datastore/index.d.ts +91 -0
- package/dist/datastore/index.d.ts.map +1 -0
- package/dist/datatype/index.d.ts +108 -0
- package/dist/discovery/local-discovery.d.ts +64 -0
- package/dist/discovery/local-discovery.d.ts.map +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fastify-controller.d.ts +27 -0
- package/dist/fastify-controller.d.ts.map +1 -0
- package/dist/fastify-plugins/blobs.d.ts +6 -0
- package/dist/fastify-plugins/blobs.d.ts.map +1 -0
- package/dist/fastify-plugins/constants.d.ts +3 -0
- package/dist/fastify-plugins/constants.d.ts.map +1 -0
- package/dist/fastify-plugins/icons.d.ts +6 -0
- package/dist/fastify-plugins/icons.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/index.d.ts +11 -0
- package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
- package/dist/fastify-plugins/utils.d.ts +23 -0
- package/dist/fastify-plugins/utils.d.ts.map +1 -0
- package/dist/generated/extensions.d.ts +44 -0
- package/dist/generated/extensions.d.ts.map +1 -0
- package/dist/generated/keys.d.ts +36 -0
- package/dist/generated/keys.d.ts.map +1 -0
- package/dist/generated/rpc.d.ts +87 -0
- package/dist/generated/rpc.d.ts.map +1 -0
- package/dist/icon-api.d.ts +109 -0
- package/dist/icon-api.d.ts.map +1 -0
- package/dist/index-writer/index.d.ts +51 -0
- package/dist/index-writer/index.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/invite-api.d.ts +70 -0
- package/dist/invite-api.d.ts.map +1 -0
- package/dist/lib/hashmap.d.ts +62 -0
- package/dist/lib/hashmap.d.ts.map +1 -0
- package/dist/lib/hypercore-helpers.d.ts +6 -0
- package/dist/lib/hypercore-helpers.d.ts.map +1 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
- package/dist/lib/ponyfills.d.ts +10 -0
- package/dist/lib/ponyfills.d.ts.map +1 -0
- package/dist/lib/string.d.ts +2 -0
- package/dist/lib/string.d.ts.map +1 -0
- package/dist/lib/timing-safe-equal.d.ts +15 -0
- package/dist/lib/timing-safe-equal.d.ts.map +1 -0
- package/dist/local-peers.d.ts +151 -0
- package/dist/local-peers.d.ts.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +178 -0
- package/dist/mapeo-manager.d.ts.map +1 -0
- package/dist/mapeo-project.d.ts +3233 -0
- package/dist/mapeo-project.d.ts.map +1 -0
- package/dist/member-api.d.ts +114 -0
- package/dist/member-api.d.ts.map +1 -0
- package/dist/roles.d.ts +157 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/schema/client.d.ts +284 -0
- package/dist/schema/client.d.ts.map +1 -0
- package/dist/schema/project.d.ts +1812 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/schema-to-drizzle.d.ts +20 -0
- package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/types.d.ts +98 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +55 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +252 -0
- package/dist/sync/core-sync-state.d.ts.map +1 -0
- package/dist/sync/namespace-sync-state.d.ts +47 -0
- package/dist/sync/namespace-sync-state.d.ts.map +1 -0
- package/dist/sync/peer-sync-controller.d.ts +44 -0
- package/dist/sync/peer-sync-controller.d.ts.map +1 -0
- package/dist/sync/sync-api.d.ts +158 -0
- package/dist/sync/sync-api.d.ts.map +1 -0
- package/dist/sync/sync-state.d.ts +40 -0
- package/dist/sync/sync-state.d.ts.map +1 -0
- package/dist/translation-api.d.ts +288 -0
- package/dist/translation-api.d.ts.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils_types.d.ts +14 -0
- package/drizzle/client/0000_bumpy_carnage.sql +33 -0
- package/drizzle/client/meta/0000_snapshot.json +199 -0
- package/drizzle/client/meta/_journal.json +13 -0
- package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
- package/drizzle/project/meta/0000_snapshot.json +1137 -0
- package/drizzle/project/meta/_journal.json +13 -0
- package/package.json +202 -0
- package/src/blob-api.js +139 -0
- package/src/blob-store/index.js +325 -0
- package/src/blob-store/live-download.js +373 -0
- package/src/config-import.js +604 -0
- package/src/constants.js +34 -0
- package/src/core-manager/bitfield-rle.js +235 -0
- package/src/core-manager/core-index.js +87 -0
- package/src/core-manager/index.js +504 -0
- package/src/core-manager/random-access-file-pool.js +30 -0
- package/src/core-manager/remote-bitfield.js +416 -0
- package/src/core-ownership.js +235 -0
- package/src/datastore/README.md +46 -0
- package/src/datastore/index.js +234 -0
- package/src/datatype/README.md +33 -0
- package/src/datatype/index.d.ts +108 -0
- package/src/datatype/index.js +358 -0
- package/src/discovery/local-discovery.js +303 -0
- package/src/errors.js +5 -0
- package/src/fastify-controller.js +84 -0
- package/src/fastify-plugins/blobs.js +139 -0
- package/src/fastify-plugins/constants.js +5 -0
- package/src/fastify-plugins/icons.js +158 -0
- package/src/fastify-plugins/maps/index.js +173 -0
- package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
- package/src/fastify-plugins/maps/static-maps.js +271 -0
- package/src/fastify-plugins/utils.js +52 -0
- package/src/generated/README.md +3 -0
- package/src/generated/extensions.d.ts +44 -0
- package/src/generated/extensions.js +196 -0
- package/src/generated/extensions.ts +237 -0
- package/src/generated/keys.d.ts +36 -0
- package/src/generated/keys.js +148 -0
- package/src/generated/keys.ts +185 -0
- package/src/generated/rpc.d.ts +87 -0
- package/src/generated/rpc.js +389 -0
- package/src/generated/rpc.ts +463 -0
- package/src/icon-api.js +282 -0
- package/src/index-writer/README.md +38 -0
- package/src/index-writer/index.js +124 -0
- package/src/index.js +16 -0
- package/src/invite-api.js +450 -0
- package/src/lib/hashmap.js +91 -0
- package/src/lib/hypercore-helpers.js +18 -0
- package/src/lib/noise-secret-stream-helpers.js +37 -0
- package/src/lib/ponyfills.js +25 -0
- package/src/lib/string.js +7 -0
- package/src/lib/timing-safe-equal.js +34 -0
- package/src/local-peers.js +737 -0
- package/src/logger.js +99 -0
- package/src/mapeo-manager.js +914 -0
- package/src/mapeo-project.js +980 -0
- package/src/member-api.js +319 -0
- package/src/roles.js +412 -0
- package/src/schema/client.js +55 -0
- package/src/schema/project.js +44 -0
- package/src/schema/schema-to-drizzle.js +118 -0
- package/src/schema/types.ts +153 -0
- package/src/schema/utils.js +51 -0
- package/src/sync/core-sync-state.js +440 -0
- package/src/sync/namespace-sync-state.js +193 -0
- package/src/sync/peer-sync-controller.js +332 -0
- package/src/sync/sync-api.js +588 -0
- package/src/sync/sync-state.js +63 -0
- package/src/translation-api.js +141 -0
- package/src/types.ts +149 -0
- package/src/utils.js +210 -0
- 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,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
|
+
}
|