@comapeo/map-server 1.0.0-pre.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 (179) hide show
  1. package/README.md +610 -0
  2. package/dist/context.d.ts +46 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +181 -0
  5. package/dist/index.d.ts +25 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +112 -0
  8. package/dist/lib/constants.d.ts +7 -0
  9. package/dist/lib/constants.d.ts.map +1 -0
  10. package/dist/lib/constants.js +6 -0
  11. package/dist/lib/download-request.d.ts +17 -0
  12. package/dist/lib/download-request.d.ts.map +1 -0
  13. package/dist/lib/download-request.js +113 -0
  14. package/dist/lib/errors.d.ts +88 -0
  15. package/dist/lib/errors.d.ts.map +1 -0
  16. package/dist/lib/errors.js +158 -0
  17. package/dist/lib/event-stream-response.d.ts +17 -0
  18. package/dist/lib/event-stream-response.d.ts.map +1 -0
  19. package/dist/lib/event-stream-response.js +39 -0
  20. package/dist/lib/event-target.d.ts +9 -0
  21. package/dist/lib/event-target.d.ts.map +1 -0
  22. package/dist/lib/event-target.js +4 -0
  23. package/dist/lib/fetch-api.d.ts +3 -0
  24. package/dist/lib/fetch-api.d.ts.map +1 -0
  25. package/dist/lib/fetch-api.js +16 -0
  26. package/dist/lib/map-share.d.ts +52 -0
  27. package/dist/lib/map-share.d.ts.map +1 -0
  28. package/dist/lib/map-share.js +142 -0
  29. package/dist/lib/secret-stream-fetch.d.ts +7 -0
  30. package/dist/lib/secret-stream-fetch.d.ts.map +1 -0
  31. package/dist/lib/secret-stream-fetch.js +34 -0
  32. package/dist/lib/self-evicting-map.d.ts +16 -0
  33. package/dist/lib/self-evicting-map.d.ts.map +1 -0
  34. package/dist/lib/self-evicting-map.js +29 -0
  35. package/dist/lib/state-update-event.d.ts +8 -0
  36. package/dist/lib/state-update-event.d.ts.map +1 -0
  37. package/dist/lib/state-update-event.js +10 -0
  38. package/dist/lib/utils.d.ts +32 -0
  39. package/dist/lib/utils.d.ts.map +1 -0
  40. package/dist/lib/utils.js +96 -0
  41. package/dist/middlewares/localhost-only.d.ts +11 -0
  42. package/dist/middlewares/localhost-only.d.ts.map +1 -0
  43. package/dist/middlewares/localhost-only.js +10 -0
  44. package/dist/middlewares/parse-request.d.ts +11 -0
  45. package/dist/middlewares/parse-request.d.ts.map +1 -0
  46. package/dist/middlewares/parse-request.js +25 -0
  47. package/dist/routes/downloads.d.ts +15 -0
  48. package/dist/routes/downloads.d.ts.map +1 -0
  49. package/dist/routes/downloads.js +60 -0
  50. package/dist/routes/map-shares.d.ts +19 -0
  51. package/dist/routes/map-shares.d.ts.map +1 -0
  52. package/dist/routes/map-shares.js +192 -0
  53. package/dist/routes/maps.d.ts +6 -0
  54. package/dist/routes/maps.d.ts.map +1 -0
  55. package/dist/routes/maps.js +118 -0
  56. package/dist/routes/root.d.ts +6 -0
  57. package/dist/routes/root.d.ts.map +1 -0
  58. package/dist/routes/root.js +29 -0
  59. package/dist/types.d.ts +110 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +96 -0
  62. package/node_modules/@envelop/instrumentation/LICENSE +21 -0
  63. package/node_modules/@envelop/instrumentation/README.md +30 -0
  64. package/node_modules/@envelop/instrumentation/cjs/index.js +5 -0
  65. package/node_modules/@envelop/instrumentation/cjs/instrumentation.js +89 -0
  66. package/node_modules/@envelop/instrumentation/cjs/package.json +1 -0
  67. package/node_modules/@envelop/instrumentation/esm/index.js +2 -0
  68. package/node_modules/@envelop/instrumentation/esm/instrumentation.js +82 -0
  69. package/node_modules/@envelop/instrumentation/package.json +57 -0
  70. package/node_modules/@envelop/instrumentation/typings/index.d.cts +1 -0
  71. package/node_modules/@envelop/instrumentation/typings/index.d.ts +1 -0
  72. package/node_modules/@envelop/instrumentation/typings/instrumentation.d.cts +44 -0
  73. package/node_modules/@envelop/instrumentation/typings/instrumentation.d.ts +44 -0
  74. package/node_modules/@whatwg-node/disposablestack/cjs/AsyncDisposableStack.js +73 -0
  75. package/node_modules/@whatwg-node/disposablestack/cjs/DisposableStack.js +62 -0
  76. package/node_modules/@whatwg-node/disposablestack/cjs/SupressedError.js +16 -0
  77. package/node_modules/@whatwg-node/disposablestack/cjs/index.js +11 -0
  78. package/node_modules/@whatwg-node/disposablestack/cjs/package.json +1 -0
  79. package/node_modules/@whatwg-node/disposablestack/cjs/symbols.js +20 -0
  80. package/node_modules/@whatwg-node/disposablestack/cjs/utils.js +11 -0
  81. package/node_modules/@whatwg-node/disposablestack/esm/AsyncDisposableStack.js +69 -0
  82. package/node_modules/@whatwg-node/disposablestack/esm/DisposableStack.js +58 -0
  83. package/node_modules/@whatwg-node/disposablestack/esm/SupressedError.js +12 -0
  84. package/node_modules/@whatwg-node/disposablestack/esm/index.js +7 -0
  85. package/node_modules/@whatwg-node/disposablestack/esm/symbols.js +16 -0
  86. package/node_modules/@whatwg-node/disposablestack/esm/utils.js +7 -0
  87. package/node_modules/@whatwg-node/disposablestack/package.json +44 -0
  88. package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.cts +15 -0
  89. package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.ts +15 -0
  90. package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.cts +14 -0
  91. package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.ts +14 -0
  92. package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.cts +5 -0
  93. package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.ts +5 -0
  94. package/node_modules/@whatwg-node/disposablestack/typings/index.d.cts +4 -0
  95. package/node_modules/@whatwg-node/disposablestack/typings/index.d.ts +4 -0
  96. package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.cts +5 -0
  97. package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.ts +5 -0
  98. package/node_modules/@whatwg-node/disposablestack/typings/utils.d.cts +2 -0
  99. package/node_modules/@whatwg-node/disposablestack/typings/utils.d.ts +2 -0
  100. package/node_modules/@whatwg-node/promise-helpers/cjs/index.js +270 -0
  101. package/node_modules/@whatwg-node/promise-helpers/cjs/package.json +1 -0
  102. package/node_modules/@whatwg-node/promise-helpers/esm/index.js +257 -0
  103. package/node_modules/@whatwg-node/promise-helpers/package.json +43 -0
  104. package/node_modules/@whatwg-node/promise-helpers/typings/index.d.cts +31 -0
  105. package/node_modules/@whatwg-node/promise-helpers/typings/index.d.ts +31 -0
  106. package/node_modules/@whatwg-node/server/README.md +590 -0
  107. package/node_modules/@whatwg-node/server/cjs/createServerAdapter.js +368 -0
  108. package/node_modules/@whatwg-node/server/cjs/index.js +17 -0
  109. package/node_modules/@whatwg-node/server/cjs/package.json +1 -0
  110. package/node_modules/@whatwg-node/server/cjs/plugins/types.js +0 -0
  111. package/node_modules/@whatwg-node/server/cjs/plugins/useContentEncoding.js +73 -0
  112. package/node_modules/@whatwg-node/server/cjs/plugins/useCors.js +124 -0
  113. package/node_modules/@whatwg-node/server/cjs/plugins/useErrorHandling.js +52 -0
  114. package/node_modules/@whatwg-node/server/cjs/types.js +0 -0
  115. package/node_modules/@whatwg-node/server/cjs/utils.js +599 -0
  116. package/node_modules/@whatwg-node/server/cjs/uwebsockets.js +241 -0
  117. package/node_modules/@whatwg-node/server/esm/createServerAdapter.js +365 -0
  118. package/node_modules/@whatwg-node/server/esm/index.js +11 -0
  119. package/node_modules/@whatwg-node/server/esm/plugins/types.js +0 -0
  120. package/node_modules/@whatwg-node/server/esm/plugins/useContentEncoding.js +70 -0
  121. package/node_modules/@whatwg-node/server/esm/plugins/useCors.js +120 -0
  122. package/node_modules/@whatwg-node/server/esm/plugins/useErrorHandling.js +46 -0
  123. package/node_modules/@whatwg-node/server/esm/types.js +0 -0
  124. package/node_modules/@whatwg-node/server/esm/utils.js +588 -0
  125. package/node_modules/@whatwg-node/server/esm/uwebsockets.js +234 -0
  126. package/node_modules/@whatwg-node/server/package.json +46 -0
  127. package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.cts +19 -0
  128. package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.ts +19 -0
  129. package/node_modules/@whatwg-node/server/typings/index.d.cts +11 -0
  130. package/node_modules/@whatwg-node/server/typings/index.d.ts +11 -0
  131. package/node_modules/@whatwg-node/server/typings/plugins/types.d.cts +76 -0
  132. package/node_modules/@whatwg-node/server/typings/plugins/types.d.ts +76 -0
  133. package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.cts +2 -0
  134. package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.ts +2 -0
  135. package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.cts +14 -0
  136. package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.ts +14 -0
  137. package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.cts +13 -0
  138. package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.ts +13 -0
  139. package/node_modules/@whatwg-node/server/typings/types.d.cts +100 -0
  140. package/node_modules/@whatwg-node/server/typings/types.d.ts +100 -0
  141. package/node_modules/@whatwg-node/server/typings/utils.d.cts +42 -0
  142. package/node_modules/@whatwg-node/server/typings/utils.d.ts +42 -0
  143. package/node_modules/@whatwg-node/server/typings/uwebsockets.d.cts +32 -0
  144. package/node_modules/@whatwg-node/server/typings/uwebsockets.d.ts +32 -0
  145. package/node_modules/tslib/CopyrightNotice.txt +15 -0
  146. package/node_modules/tslib/LICENSE.txt +12 -0
  147. package/node_modules/tslib/README.md +164 -0
  148. package/node_modules/tslib/SECURITY.md +41 -0
  149. package/node_modules/tslib/modules/index.d.ts +38 -0
  150. package/node_modules/tslib/modules/index.js +70 -0
  151. package/node_modules/tslib/modules/package.json +3 -0
  152. package/node_modules/tslib/package.json +47 -0
  153. package/node_modules/tslib/tslib.d.ts +460 -0
  154. package/node_modules/tslib/tslib.es6.html +1 -0
  155. package/node_modules/tslib/tslib.es6.js +402 -0
  156. package/node_modules/tslib/tslib.es6.mjs +401 -0
  157. package/node_modules/tslib/tslib.html +1 -0
  158. package/node_modules/tslib/tslib.js +484 -0
  159. package/package.json +87 -0
  160. package/src/context.ts +203 -0
  161. package/src/index.ts +193 -0
  162. package/src/lib/constants.ts +6 -0
  163. package/src/lib/download-request.ts +142 -0
  164. package/src/lib/errors.ts +187 -0
  165. package/src/lib/event-stream-response.ts +57 -0
  166. package/src/lib/event-target.ts +11 -0
  167. package/src/lib/fetch-api.ts +18 -0
  168. package/src/lib/map-share.ts +185 -0
  169. package/src/lib/secret-stream-fetch.ts +42 -0
  170. package/src/lib/self-evicting-map.ts +35 -0
  171. package/src/lib/state-update-event.ts +14 -0
  172. package/src/lib/utils.ts +110 -0
  173. package/src/middlewares/localhost-only.ts +16 -0
  174. package/src/middlewares/parse-request.ts +34 -0
  175. package/src/routes/downloads.ts +92 -0
  176. package/src/routes/map-shares.ts +246 -0
  177. package/src/routes/maps.ts +146 -0
  178. package/src/routes/root.ts +37 -0
  179. package/src/types.ts +152 -0
package/src/context.ts ADDED
@@ -0,0 +1,203 @@
1
+ import fs, { type Stats } from 'node:fs'
2
+ import fsPromises from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { Readable, Writable } from 'node:stream'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+
7
+ import { Reader } from 'styled-map-package'
8
+ import type { SetRequired } from 'type-fest'
9
+
10
+ import type { ServerOptions } from './index.js'
11
+ import { CUSTOM_MAP_ID, FALLBACK_MAP_ID } from './lib/constants.js'
12
+ import { errors } from './lib/errors.js'
13
+ import {
14
+ getErrorCode,
15
+ getStyleBbox,
16
+ getStyleMaxZoom,
17
+ getStyleMinZoom,
18
+ noop,
19
+ } from './lib/utils.js'
20
+
21
+ type ContextOptions = SetRequired<ServerOptions, 'keyPair'> & {
22
+ getRemotePort: () => Promise<number>
23
+ }
24
+
25
+ let tmpCounter = 0
26
+
27
+ export class Context {
28
+ #defaultOnlineStyleUrl: URL
29
+ #mapFileUrls: Map<string, URL>
30
+ #mapReaders: Map<string, Promise<Reader>> = new Map()
31
+ #keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }
32
+ getRemotePort: () => Promise<number>
33
+
34
+ constructor({
35
+ defaultOnlineStyleUrl,
36
+ customMapPath,
37
+ fallbackMapPath,
38
+ keyPair,
39
+ getRemotePort,
40
+ }: ContextOptions) {
41
+ this.#defaultOnlineStyleUrl = new URL(defaultOnlineStyleUrl)
42
+ this.#mapFileUrls = new Map([
43
+ [
44
+ CUSTOM_MAP_ID,
45
+ typeof customMapPath === 'string'
46
+ ? pathToFileURL(customMapPath)
47
+ : customMapPath,
48
+ ],
49
+ [
50
+ FALLBACK_MAP_ID,
51
+ typeof fallbackMapPath === 'string'
52
+ ? pathToFileURL(fallbackMapPath)
53
+ : fallbackMapPath,
54
+ ],
55
+ ])
56
+ this.#keyPair = keyPair
57
+ this.getRemotePort = getRemotePort
58
+ }
59
+ getDefaultOnlineStyleUrl() {
60
+ return this.#defaultOnlineStyleUrl
61
+ }
62
+ getKeyPair() {
63
+ return this.#keyPair
64
+ }
65
+ async getMapInfo(mapId: string) {
66
+ const mapFileUrl = this.#mapFileUrls.get(mapId)
67
+ if (!mapFileUrl) {
68
+ throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
69
+ }
70
+ let stats: Stats
71
+ try {
72
+ stats = await fsPromises.stat(mapFileUrl)
73
+ } catch (err) {
74
+ if (getErrorCode(err) === 'ENOENT') {
75
+ throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
76
+ }
77
+ throw err
78
+ }
79
+ const reader = await this.getReader(mapId)
80
+ const style = await reader.getStyle()
81
+ const mapName = style.name || path.basename(fileURLToPath(mapFileUrl))
82
+ return {
83
+ mapId,
84
+ mapName,
85
+ bounds: getStyleBbox(style),
86
+ maxzoom: getStyleMaxZoom(style),
87
+ minzoom: getStyleMinZoom(style),
88
+ estimatedSizeBytes: stats.size,
89
+ mapCreated: stats.ctimeMs,
90
+ }
91
+ }
92
+ getReader(mapId: string) {
93
+ const readerPromise = this.#mapReaders.get(mapId)
94
+ if (readerPromise) {
95
+ return readerPromise
96
+ }
97
+ const mapFileUrl = this.#mapFileUrls.get(mapId)
98
+ if (!mapFileUrl) {
99
+ throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
100
+ }
101
+ const reader = new Reader(fileURLToPath(mapFileUrl))
102
+ this.#mapReaders.set(mapId, Promise.resolve(reader))
103
+ return Promise.resolve(reader)
104
+ }
105
+ createMapReadableStream(mapId: string) {
106
+ const mapFileUrl = this.#mapFileUrls.get(mapId)
107
+ if (!mapFileUrl) {
108
+ throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
109
+ }
110
+ return Readable.toWeb(
111
+ fs.createReadStream(mapFileUrl),
112
+ ) as ReadableStream<Uint8Array> // small discrepancy in types
113
+ }
114
+ /**
115
+ * Creates a writable stream to write map data to the specified map ID.
116
+ * The data is first written to a temporary file, and once the stream is closed,
117
+ * the temporary file replaces the existing map file. This ensures that the map
118
+ * file is only updated when the write operation is fully complete.
119
+ *
120
+ * @param mapId - The ID of the map to write data to.
121
+ * @returns A writable stream to write map data.
122
+ */
123
+ createMapWritableStream(mapId: string) {
124
+ const mapFileUrl = this.#mapFileUrls.get(mapId)
125
+ if (!mapFileUrl) {
126
+ throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
127
+ }
128
+ const tempPath = `${fileURLToPath(mapFileUrl)}.download-${tmpCounter++}`
129
+ const writable = Writable.toWeb(fs.createWriteStream(tempPath))
130
+ const writer = writable.getWriter()
131
+ return new WritableStream({
132
+ async write(chunk) {
133
+ await writer.write(chunk)
134
+ },
135
+ close: async () => {
136
+ // Finish writing to the temp file
137
+ await writer.close()
138
+
139
+ // Validate the uploaded map file BEFORE replacing the existing one
140
+ const tempReader = new Reader(tempPath)
141
+ try {
142
+ await tempReader.opened()
143
+ } catch {
144
+ // Clean up temp file on validation failure
145
+ await fsPromises.unlink(tempPath).catch(noop)
146
+ throw new errors.INVALID_MAP_FILE()
147
+ } finally {
148
+ await tempReader.close().catch(noop)
149
+ }
150
+
151
+ // Graceful replacement of SMP Reader when map file is updated
152
+ const readerPromise = (async () => {
153
+ const existingReaderPromise = this.#mapReaders.get(mapId)
154
+ if (existingReaderPromise) {
155
+ const existingReader = await existingReaderPromise
156
+ await existingReader.close().catch(noop)
157
+ }
158
+ await fsPromises.cp(tempPath, mapFileUrl, { force: true })
159
+ return new Reader(fileURLToPath(mapFileUrl))
160
+ })()
161
+ this.#mapReaders.set(mapId, readerPromise)
162
+ // Wait for the file copy to complete before closing the stream
163
+ await readerPromise
164
+ },
165
+ async abort(err) {
166
+ try {
167
+ await writer.abort(err)
168
+ } finally {
169
+ await fsPromises.unlink(tempPath).catch(noop)
170
+ }
171
+ },
172
+ })
173
+ }
174
+ /**
175
+ * Deletes the map file for the specified map ID.
176
+ * Closes any existing reader and removes it from the cache.
177
+ *
178
+ * @param mapId - The ID of the map to delete.
179
+ */
180
+ async deleteMap(mapId: string) {
181
+ const mapFileUrl = this.#mapFileUrls.get(mapId)
182
+ if (!mapFileUrl) {
183
+ throw new errors.MAP_NOT_FOUND(`Map ID not found: ${mapId}`)
184
+ }
185
+ // Close and remove the reader if it exists
186
+ const existingReaderPromise = this.#mapReaders.get(mapId)
187
+ if (existingReaderPromise) {
188
+ const existingReader = await existingReaderPromise
189
+ await existingReader.close().catch(noop)
190
+ this.#mapReaders.delete(mapId)
191
+ }
192
+ // Delete the map file
193
+ const mapFilePath = fileURLToPath(mapFileUrl)
194
+ try {
195
+ await fsPromises.unlink(mapFilePath)
196
+ } catch (err) {
197
+ if (getErrorCode(err) === 'ENOENT') {
198
+ throw new errors.MAP_NOT_FOUND(`Map not found: ${mapId}`)
199
+ }
200
+ throw err
201
+ }
202
+ }
203
+ }
package/src/index.ts ADDED
@@ -0,0 +1,193 @@
1
+ import assert from 'node:assert'
2
+ import { once } from 'node:events'
3
+ import http from 'node:http'
4
+ import { type AddressInfo } from 'node:net'
5
+
6
+ import { createServerAdapter } from '@whatwg-node/server'
7
+ import pDefer from 'p-defer'
8
+ import {
9
+ Agent,
10
+ createServer as createSecretStreamServer,
11
+ } from 'secret-stream-http'
12
+ import z32 from 'z32'
13
+
14
+ import { Context } from './context.js'
15
+ import { fetchAPI } from './lib/fetch-api.js'
16
+ import { RootRouter } from './routes/root.js'
17
+ import type { FetchContext } from './types.js'
18
+
19
+ export type {
20
+ MapInfo,
21
+ MapShareState,
22
+ MapShareStateUpdate,
23
+ DownloadStateUpdate,
24
+ } from './types.js'
25
+ export type { DownloadState } from './lib/download-request.js'
26
+ export type {
27
+ MapShareCreateParams,
28
+ MapShareDeclineParams,
29
+ } from './routes/map-shares.js'
30
+ export type { DownloadCreateParams } from './routes/downloads.js'
31
+
32
+ export type ServerOptions = {
33
+ defaultOnlineStyleUrl: string | URL
34
+ customMapPath: string | URL
35
+ fallbackMapPath: string | URL
36
+ keyPair?: {
37
+ publicKey: Uint8Array
38
+ secretKey: Uint8Array
39
+ }
40
+ }
41
+
42
+ export type ListenOptions = {
43
+ localPort?: number
44
+ remotePort?: number
45
+ }
46
+
47
+ type ListenResult = {
48
+ localPort: number
49
+ remotePort: number
50
+ }
51
+
52
+ export function createServer(options: ServerOptions) {
53
+ validateOptions(options)
54
+ if (!options.keyPair) {
55
+ options.keyPair = Agent.keyPair()
56
+ }
57
+
58
+ const deferredListen = pDefer<ListenResult>()
59
+ const context = new Context({
60
+ ...options,
61
+ keyPair: options.keyPair,
62
+ getRemotePort: async () => {
63
+ const listenOptions = await deferredListen.promise
64
+ return listenOptions.remotePort
65
+ },
66
+ })
67
+ const router = RootRouter({ base: '/' }, context)
68
+ // Use native fetch API to avoid ponyfill bugs with stream error propagation
69
+ const serverAdapter = createServerAdapter<FetchContext>(router.fetch, {
70
+ fetchAPI,
71
+ })
72
+ const localHttpServer = http.createServer((req, res) => {
73
+ serverAdapter(req, res, { isLocalhost: true })
74
+ })
75
+
76
+ const remoteHttpServer = http.createServer((req, res) => {
77
+ serverAdapter(req, res, {
78
+ isLocalhost: false,
79
+ // @ts-expect-error - the types for this are too hard and making them work would not add any type safety.
80
+ remoteDeviceId: z32.encode(req.socket.remotePublicKey),
81
+ })
82
+ })
83
+ const secretStreamServer = createSecretStreamServer(remoteHttpServer, {
84
+ keyPair: options.keyPair,
85
+ })
86
+
87
+ // Track connections for proper cleanup
88
+ const connections = new Set<any>()
89
+ const onConnection = (socket: any) => {
90
+ connections.add(socket)
91
+ socket.once('close', () => {
92
+ connections.delete(socket)
93
+ })
94
+ }
95
+ localHttpServer.on('connection', onConnection)
96
+ secretStreamServer.on('connection', onConnection)
97
+
98
+ return {
99
+ async listen(opts: ListenOptions = {}) {
100
+ localHttpServer.listen(opts.localPort, '127.0.0.1')
101
+ secretStreamServer.listen(opts.remotePort, '0.0.0.0')
102
+ await Promise.all([
103
+ once(localHttpServer, 'listening'),
104
+ once(secretStreamServer, 'listening'),
105
+ ])
106
+ const localPort = (localHttpServer.address() as AddressInfo).port
107
+ const remotePort = (secretStreamServer.address() as AddressInfo).port
108
+ deferredListen.resolve({ localPort, remotePort })
109
+ return { localPort, remotePort }
110
+ },
111
+ async close() {
112
+ // Remove connection listeners
113
+ localHttpServer.off('connection', onConnection)
114
+ secretStreamServer.off('connection', onConnection)
115
+ localHttpServer.close()
116
+ secretStreamServer.close()
117
+ // Destroy all active connections to ensure clean shutdown
118
+ for (const socket of connections) {
119
+ socket.destroy()
120
+ }
121
+ connections.clear()
122
+ await Promise.all([
123
+ once(localHttpServer, 'close'),
124
+ once(secretStreamServer, 'close'),
125
+ ])
126
+ },
127
+ }
128
+ }
129
+
130
+ function validateOptions(options: unknown): asserts options is ServerOptions {
131
+ assert(
132
+ typeof options === 'object' && options !== null,
133
+ new TypeError('options must be an object'),
134
+ )
135
+ assert(
136
+ 'defaultOnlineStyleUrl' in options,
137
+ new TypeError('missing defaultOnlineStyleUrl'),
138
+ )
139
+ assert('customMapPath' in options, new TypeError('missing customMapPath'))
140
+ assert('fallbackMapPath' in options, new TypeError('missing fallbackMapPath'))
141
+
142
+ assert(
143
+ typeof options.defaultOnlineStyleUrl === 'string' ||
144
+ options.defaultOnlineStyleUrl instanceof URL,
145
+ new TypeError('defaultOnlineStyleUrl must be a string or URL'),
146
+ )
147
+ assert(
148
+ URL.canParse(options.defaultOnlineStyleUrl),
149
+ new TypeError('defaultOnlineStyleUrl must be a valid URL'),
150
+ )
151
+ assert(
152
+ (typeof options.customMapPath === 'string' && options.customMapPath) ||
153
+ options.customMapPath instanceof URL,
154
+ new TypeError('customMapPath must be a string or URL'),
155
+ )
156
+ assert(
157
+ (typeof options.fallbackMapPath === 'string' && options.fallbackMapPath) ||
158
+ options.fallbackMapPath instanceof URL,
159
+ new TypeError('fallbackMapPath must be a string or URL'),
160
+ )
161
+ const parsedOptions: ServerOptions = {
162
+ defaultOnlineStyleUrl: options.defaultOnlineStyleUrl,
163
+ customMapPath: options.customMapPath,
164
+ fallbackMapPath: options.fallbackMapPath,
165
+ }
166
+
167
+ if ('keyPair' in options && options.keyPair !== undefined) {
168
+ assert(
169
+ typeof options.keyPair === 'object' && options.keyPair !== null,
170
+ new TypeError('keyPair must be an object'),
171
+ )
172
+ assert(
173
+ 'publicKey' in options.keyPair,
174
+ new TypeError('keyPair must have a publicKey'),
175
+ )
176
+ assert(
177
+ options.keyPair.publicKey instanceof Uint8Array,
178
+ new TypeError('keyPair.publicKey must be a Uint8Array'),
179
+ )
180
+ assert(
181
+ 'secretKey' in options.keyPair,
182
+ new TypeError('keyPair must have a secretKey'),
183
+ )
184
+ assert(
185
+ options.keyPair.secretKey instanceof Uint8Array,
186
+ new TypeError('keyPair.secretKey must be a Uint8Array'),
187
+ )
188
+ parsedOptions.keyPair = {
189
+ publicKey: options.keyPair.publicKey,
190
+ secretKey: options.keyPair.secretKey,
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,6 @@
1
+ /** Hard-coded mapId for the custom (offline) background map */
2
+ export const CUSTOM_MAP_ID = 'custom'
3
+ /** Hard-coded mapId for the fallback (offline) background map */
4
+ export const FALLBACK_MAP_ID = 'fallback'
5
+ /** Hard-coded mapId for the default (online) background map */
6
+ export const DEFAULT_MAP_ID = 'default'
@@ -0,0 +1,142 @@
1
+ import { Agent as SecretStreamAgent } from 'secret-stream-http'
2
+ import z32 from 'z32'
3
+
4
+ import { TypedEventTarget } from '../lib/event-target.js'
5
+ import type { DownloadCreateParams } from '../routes/downloads.js'
6
+ import { type DownloadStateUpdate } from '../types.js'
7
+ import { StatusError } from './errors.js'
8
+ import { errors, jsonError } from './errors.js'
9
+ import { secretStreamFetch } from './secret-stream-fetch.js'
10
+ import { StateUpdateEvent } from './state-update-event.js'
11
+ import { addTrailingSlash, generateId, getErrorCode, noop } from './utils.js'
12
+
13
+ export type DownloadState = DownloadStateUpdate &
14
+ Omit<DownloadCreateParams, 'mapShareUrls'> & { downloadId: string }
15
+
16
+ export class DownloadRequest extends TypedEventTarget<
17
+ InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
18
+ > {
19
+ #state: DownloadState
20
+ #abortController = new AbortController()
21
+ #transform = new TransformStream({
22
+ transform: (chunk, controller) => {
23
+ if (this.#state.status !== 'downloading') {
24
+ throw new Error('Download has been cancelled or encountered an error')
25
+ }
26
+ this.#updateState({
27
+ status: 'downloading',
28
+ bytesDownloaded: this.#state.bytesDownloaded + chunk.byteLength,
29
+ })
30
+ controller.enqueue(chunk)
31
+ },
32
+ })
33
+ #dispatcher: SecretStreamAgent
34
+
35
+ constructor(
36
+ stream: WritableStream<Uint8Array>,
37
+ { mapShareUrls, ...rest }: DownloadCreateParams,
38
+ keyPair: { publicKey: Uint8Array; secretKey: Uint8Array },
39
+ ) {
40
+ super()
41
+ this.#state = {
42
+ ...rest,
43
+ status: 'downloading',
44
+ bytesDownloaded: 0,
45
+ downloadId: generateId(),
46
+ }
47
+ let remotePublicKey: Uint8Array
48
+ try {
49
+ remotePublicKey = z32.decode(this.#state.senderDeviceId)
50
+ } catch {
51
+ throw new errors.INVALID_SENDER_DEVICE_ID(
52
+ `Invalid sender device ID: ${this.#state.senderDeviceId}`,
53
+ )
54
+ }
55
+ if (remotePublicKey.length !== 32) {
56
+ throw new errors.INVALID_SENDER_DEVICE_ID(
57
+ `Invalid sender device ID: ${this.#state.senderDeviceId}`,
58
+ )
59
+ }
60
+ this.#dispatcher = new SecretStreamAgent({ remotePublicKey, keyPair })
61
+ this.#start({ mapShareUrls, stream, remotePublicKey, keyPair }).catch(
62
+ async (error) => {
63
+ // In case the error happens before we pipe to the stream, we need to abort the stream
64
+ await stream.abort().catch(noop)
65
+ if (error.name === 'AbortError') {
66
+ this.#updateState({ status: 'aborted' })
67
+ } else if (getErrorCode(error) === 'DOWNLOAD_SHARE_CANCELED') {
68
+ this.#updateState({ status: 'canceled' })
69
+ } else if (getErrorCode(error)) {
70
+ // Specific known error from the server
71
+ this.#updateState({ status: 'error', error })
72
+ } else {
73
+ // Once the download has started, the sender can only close the
74
+ // connection to cancel the download, which we only see as an
75
+ // ECONNRESET error here, which could happen for multiple reasons.
76
+ // Rather than immediately updating the state to error, we first check
77
+ // with the sender to see if we can access the status of the share,
78
+ // namely whether it was canceled, or if a different error occurred on
79
+ // the server side.
80
+ try {
81
+ const response = await secretStreamFetch(mapShareUrls, {
82
+ dispatcher: this.#dispatcher,
83
+ signal: AbortSignal.timeout(2000),
84
+ })
85
+ const json = await response.json()
86
+ if (json.status) {
87
+ this.#updateState({ status: json.status, error: json.error })
88
+ return
89
+ }
90
+ } catch {
91
+ // Ignore errors from checking the status and update state with original error
92
+ }
93
+ this.#updateState({ status: 'error', error: jsonError(error) })
94
+ }
95
+ },
96
+ )
97
+ }
98
+
99
+ async #start({
100
+ mapShareUrls,
101
+ stream,
102
+ }: {
103
+ mapShareUrls: string[]
104
+ stream: WritableStream<Uint8Array>
105
+ remotePublicKey: Uint8Array
106
+ keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }
107
+ }) {
108
+ const downloadUrls = mapShareUrls.map(
109
+ (baseUrl) => new URL('download', addTrailingSlash(baseUrl)),
110
+ )
111
+ const response = await secretStreamFetch(downloadUrls, {
112
+ dispatcher: this.#dispatcher,
113
+ })
114
+ if (!response.body) {
115
+ throw new errors.DOWNLOAD_ERROR('Could not connect to map share sender')
116
+ }
117
+ if (!response.ok) {
118
+ throw new StatusError(response.status, await response.json())
119
+ }
120
+ if (this.#abortController.signal.aborted) {
121
+ response.body.cancel().catch(noop)
122
+ throw new DOMException('Download aborted', 'AbortError')
123
+ }
124
+ await response.body.pipeThrough(this.#transform).pipeTo(stream, {
125
+ signal: this.#abortController.signal,
126
+ })
127
+ this.#updateState({ status: 'completed' })
128
+ }
129
+
130
+ get state() {
131
+ return this.#state
132
+ }
133
+
134
+ cancel() {
135
+ this.#abortController.abort()
136
+ }
137
+
138
+ #updateState(update: DownloadStateUpdate) {
139
+ this.#state = { ...this.#state, ...update }
140
+ this.dispatchEvent(new StateUpdateEvent(update))
141
+ }
142
+ }