@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.
- package/README.md +610 -0
- package/dist/context.d.ts +46 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +181 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +112 -0
- package/dist/lib/constants.d.ts +7 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +6 -0
- package/dist/lib/download-request.d.ts +17 -0
- package/dist/lib/download-request.d.ts.map +1 -0
- package/dist/lib/download-request.js +113 -0
- package/dist/lib/errors.d.ts +88 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +158 -0
- package/dist/lib/event-stream-response.d.ts +17 -0
- package/dist/lib/event-stream-response.d.ts.map +1 -0
- package/dist/lib/event-stream-response.js +39 -0
- package/dist/lib/event-target.d.ts +9 -0
- package/dist/lib/event-target.d.ts.map +1 -0
- package/dist/lib/event-target.js +4 -0
- package/dist/lib/fetch-api.d.ts +3 -0
- package/dist/lib/fetch-api.d.ts.map +1 -0
- package/dist/lib/fetch-api.js +16 -0
- package/dist/lib/map-share.d.ts +52 -0
- package/dist/lib/map-share.d.ts.map +1 -0
- package/dist/lib/map-share.js +142 -0
- package/dist/lib/secret-stream-fetch.d.ts +7 -0
- package/dist/lib/secret-stream-fetch.d.ts.map +1 -0
- package/dist/lib/secret-stream-fetch.js +34 -0
- package/dist/lib/self-evicting-map.d.ts +16 -0
- package/dist/lib/self-evicting-map.d.ts.map +1 -0
- package/dist/lib/self-evicting-map.js +29 -0
- package/dist/lib/state-update-event.d.ts +8 -0
- package/dist/lib/state-update-event.d.ts.map +1 -0
- package/dist/lib/state-update-event.js +10 -0
- package/dist/lib/utils.d.ts +32 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +96 -0
- package/dist/middlewares/localhost-only.d.ts +11 -0
- package/dist/middlewares/localhost-only.d.ts.map +1 -0
- package/dist/middlewares/localhost-only.js +10 -0
- package/dist/middlewares/parse-request.d.ts +11 -0
- package/dist/middlewares/parse-request.d.ts.map +1 -0
- package/dist/middlewares/parse-request.js +25 -0
- package/dist/routes/downloads.d.ts +15 -0
- package/dist/routes/downloads.d.ts.map +1 -0
- package/dist/routes/downloads.js +60 -0
- package/dist/routes/map-shares.d.ts +19 -0
- package/dist/routes/map-shares.d.ts.map +1 -0
- package/dist/routes/map-shares.js +192 -0
- package/dist/routes/maps.d.ts +6 -0
- package/dist/routes/maps.d.ts.map +1 -0
- package/dist/routes/maps.js +118 -0
- package/dist/routes/root.d.ts +6 -0
- package/dist/routes/root.d.ts.map +1 -0
- package/dist/routes/root.js +29 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +96 -0
- package/node_modules/@envelop/instrumentation/LICENSE +21 -0
- package/node_modules/@envelop/instrumentation/README.md +30 -0
- package/node_modules/@envelop/instrumentation/cjs/index.js +5 -0
- package/node_modules/@envelop/instrumentation/cjs/instrumentation.js +89 -0
- package/node_modules/@envelop/instrumentation/cjs/package.json +1 -0
- package/node_modules/@envelop/instrumentation/esm/index.js +2 -0
- package/node_modules/@envelop/instrumentation/esm/instrumentation.js +82 -0
- package/node_modules/@envelop/instrumentation/package.json +57 -0
- package/node_modules/@envelop/instrumentation/typings/index.d.cts +1 -0
- package/node_modules/@envelop/instrumentation/typings/index.d.ts +1 -0
- package/node_modules/@envelop/instrumentation/typings/instrumentation.d.cts +44 -0
- package/node_modules/@envelop/instrumentation/typings/instrumentation.d.ts +44 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/AsyncDisposableStack.js +73 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/DisposableStack.js +62 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/SupressedError.js +16 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/index.js +11 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/symbols.js +20 -0
- package/node_modules/@whatwg-node/disposablestack/cjs/utils.js +11 -0
- package/node_modules/@whatwg-node/disposablestack/esm/AsyncDisposableStack.js +69 -0
- package/node_modules/@whatwg-node/disposablestack/esm/DisposableStack.js +58 -0
- package/node_modules/@whatwg-node/disposablestack/esm/SupressedError.js +12 -0
- package/node_modules/@whatwg-node/disposablestack/esm/index.js +7 -0
- package/node_modules/@whatwg-node/disposablestack/esm/symbols.js +16 -0
- package/node_modules/@whatwg-node/disposablestack/esm/utils.js +7 -0
- package/node_modules/@whatwg-node/disposablestack/package.json +44 -0
- package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.cts +15 -0
- package/node_modules/@whatwg-node/disposablestack/typings/AsyncDisposableStack.d.ts +15 -0
- package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.cts +14 -0
- package/node_modules/@whatwg-node/disposablestack/typings/DisposableStack.d.ts +14 -0
- package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.cts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/SupressedError.d.ts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/index.d.cts +4 -0
- package/node_modules/@whatwg-node/disposablestack/typings/index.d.ts +4 -0
- package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.cts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/symbols.d.ts +5 -0
- package/node_modules/@whatwg-node/disposablestack/typings/utils.d.cts +2 -0
- package/node_modules/@whatwg-node/disposablestack/typings/utils.d.ts +2 -0
- package/node_modules/@whatwg-node/promise-helpers/cjs/index.js +270 -0
- package/node_modules/@whatwg-node/promise-helpers/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/promise-helpers/esm/index.js +257 -0
- package/node_modules/@whatwg-node/promise-helpers/package.json +43 -0
- package/node_modules/@whatwg-node/promise-helpers/typings/index.d.cts +31 -0
- package/node_modules/@whatwg-node/promise-helpers/typings/index.d.ts +31 -0
- package/node_modules/@whatwg-node/server/README.md +590 -0
- package/node_modules/@whatwg-node/server/cjs/createServerAdapter.js +368 -0
- package/node_modules/@whatwg-node/server/cjs/index.js +17 -0
- package/node_modules/@whatwg-node/server/cjs/package.json +1 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/types.js +0 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useContentEncoding.js +73 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useCors.js +124 -0
- package/node_modules/@whatwg-node/server/cjs/plugins/useErrorHandling.js +52 -0
- package/node_modules/@whatwg-node/server/cjs/types.js +0 -0
- package/node_modules/@whatwg-node/server/cjs/utils.js +599 -0
- package/node_modules/@whatwg-node/server/cjs/uwebsockets.js +241 -0
- package/node_modules/@whatwg-node/server/esm/createServerAdapter.js +365 -0
- package/node_modules/@whatwg-node/server/esm/index.js +11 -0
- package/node_modules/@whatwg-node/server/esm/plugins/types.js +0 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useContentEncoding.js +70 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useCors.js +120 -0
- package/node_modules/@whatwg-node/server/esm/plugins/useErrorHandling.js +46 -0
- package/node_modules/@whatwg-node/server/esm/types.js +0 -0
- package/node_modules/@whatwg-node/server/esm/utils.js +588 -0
- package/node_modules/@whatwg-node/server/esm/uwebsockets.js +234 -0
- package/node_modules/@whatwg-node/server/package.json +46 -0
- package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.cts +19 -0
- package/node_modules/@whatwg-node/server/typings/createServerAdapter.d.ts +19 -0
- package/node_modules/@whatwg-node/server/typings/index.d.cts +11 -0
- package/node_modules/@whatwg-node/server/typings/index.d.ts +11 -0
- package/node_modules/@whatwg-node/server/typings/plugins/types.d.cts +76 -0
- package/node_modules/@whatwg-node/server/typings/plugins/types.d.ts +76 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.cts +2 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useContentEncoding.d.ts +2 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.cts +14 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useCors.d.ts +14 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.cts +13 -0
- package/node_modules/@whatwg-node/server/typings/plugins/useErrorHandling.d.ts +13 -0
- package/node_modules/@whatwg-node/server/typings/types.d.cts +100 -0
- package/node_modules/@whatwg-node/server/typings/types.d.ts +100 -0
- package/node_modules/@whatwg-node/server/typings/utils.d.cts +42 -0
- package/node_modules/@whatwg-node/server/typings/utils.d.ts +42 -0
- package/node_modules/@whatwg-node/server/typings/uwebsockets.d.cts +32 -0
- package/node_modules/@whatwg-node/server/typings/uwebsockets.d.ts +32 -0
- package/node_modules/tslib/CopyrightNotice.txt +15 -0
- package/node_modules/tslib/LICENSE.txt +12 -0
- package/node_modules/tslib/README.md +164 -0
- package/node_modules/tslib/SECURITY.md +41 -0
- package/node_modules/tslib/modules/index.d.ts +38 -0
- package/node_modules/tslib/modules/index.js +70 -0
- package/node_modules/tslib/modules/package.json +3 -0
- package/node_modules/tslib/package.json +47 -0
- package/node_modules/tslib/tslib.d.ts +460 -0
- package/node_modules/tslib/tslib.es6.html +1 -0
- package/node_modules/tslib/tslib.es6.js +402 -0
- package/node_modules/tslib/tslib.es6.mjs +401 -0
- package/node_modules/tslib/tslib.html +1 -0
- package/node_modules/tslib/tslib.js +484 -0
- package/package.json +87 -0
- package/src/context.ts +203 -0
- package/src/index.ts +193 -0
- package/src/lib/constants.ts +6 -0
- package/src/lib/download-request.ts +142 -0
- package/src/lib/errors.ts +187 -0
- package/src/lib/event-stream-response.ts +57 -0
- package/src/lib/event-target.ts +11 -0
- package/src/lib/fetch-api.ts +18 -0
- package/src/lib/map-share.ts +185 -0
- package/src/lib/secret-stream-fetch.ts +42 -0
- package/src/lib/self-evicting-map.ts +35 -0
- package/src/lib/state-update-event.ts +14 -0
- package/src/lib/utils.ts +110 -0
- package/src/middlewares/localhost-only.ts +16 -0
- package/src/middlewares/parse-request.ts +34 -0
- package/src/routes/downloads.ts +92 -0
- package/src/routes/map-shares.ts +246 -0
- package/src/routes/maps.ts +146 -0
- package/src/routes/root.ts +37 -0
- 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
|
+
}
|