@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
@@ -0,0 +1,34 @@
1
+ import { type RequestHandler, type IRequestStrict, type IRequest } from 'itty-router'
2
+ import { Type as T, type StaticType } from 'typebox'
3
+ import { Compile } from 'typebox/compile'
4
+
5
+ import { errors } from '../lib/errors.js'
6
+
7
+ /**
8
+ * A small helper to create middleware that parses and validates the request
9
+ * body against the given schema. Downstream handlers can access the type-safe
10
+ * parsed body via `request.parsed`.
11
+ */
12
+ export const parseRequest = <
13
+ TSchema extends T.TSchema,
14
+ TRequest extends IRequest = IRequestStrict,
15
+ >(
16
+ schema: TSchema,
17
+ ): RequestHandler<
18
+ TRequest & { parsed: StaticType<[], 'Decode', {}, {}, TSchema> }
19
+ > => {
20
+ const C = Compile(schema)
21
+ return async (request) => {
22
+ try {
23
+ const json = await request.json()
24
+ // Use Check to validate without type coercion
25
+ if (!C.Check(json)) {
26
+ throw new errors.INVALID_REQUEST()
27
+ }
28
+ request.parsed = json as StaticType<[], 'Decode', {}, {}, TSchema>
29
+ } catch (error) {
30
+ if ('status' in (error as object)) throw error
31
+ throw new errors.INVALID_REQUEST()
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,92 @@
1
+ import { IttyRouter } from 'itty-router'
2
+ import { Type as T, type Static } from 'typebox'
3
+
4
+ import type { Context } from '../context.js'
5
+ import { CUSTOM_MAP_ID } from '../lib/constants.js'
6
+ import { DownloadRequest } from '../lib/download-request.js'
7
+ import { errors } from '../lib/errors.js'
8
+ import { createEventStreamResponse } from '../lib/event-stream-response.js'
9
+ import { SelfEvictingTimeoutMap } from '../lib/self-evicting-map.js'
10
+ import { addTrailingSlash } from '../lib/utils.js'
11
+ import { parseRequest } from '../middlewares/parse-request.js'
12
+ import {
13
+ MapShareUrls,
14
+ EstimatedSizeBytes,
15
+ ShareId,
16
+ type RouterExternal,
17
+ } from '../types.js'
18
+
19
+ const DownloadCreateRequest = T.Object({
20
+ senderDeviceId: T.String({
21
+ minLength: 1,
22
+ description: 'The ID of the device that is sending the map share',
23
+ }),
24
+ mapShareUrls: MapShareUrls,
25
+ shareId: ShareId,
26
+ estimatedSizeBytes: EstimatedSizeBytes,
27
+ })
28
+
29
+ export type DownloadCreateParams = Static<typeof DownloadCreateRequest>
30
+
31
+ export function DownloadsRouter(
32
+ { base }: { base: string },
33
+ ctx: Context,
34
+ ): RouterExternal {
35
+ const downloads = new SelfEvictingTimeoutMap<string, DownloadRequest>()
36
+ const router = IttyRouter({ base })
37
+
38
+ router.post('/', parseRequest(DownloadCreateRequest), async (request) => {
39
+ const writable = ctx.createMapWritableStream(CUSTOM_MAP_ID)
40
+ const download = new DownloadRequest(
41
+ writable,
42
+ request.parsed,
43
+ ctx.getKeyPair(),
44
+ )
45
+ downloads.set(download.state.downloadId, download)
46
+ return Response.json(download.state, {
47
+ status: 201,
48
+ headers: {
49
+ Location: new URL(
50
+ download.state.downloadId,
51
+ addTrailingSlash(request.url),
52
+ ).href,
53
+ },
54
+ })
55
+ })
56
+
57
+ router.get('/', () => {
58
+ return Array.from(downloads.values()).map((d) => d.state)
59
+ })
60
+
61
+ router.get('/:downloadId', async (request) => {
62
+ return getDownload(request.params.downloadId).state
63
+ })
64
+
65
+ router.get('/:downloadId/events', async (request): Promise<Response> => {
66
+ const download = getDownload(request.params.downloadId)
67
+ return createEventStreamResponse(download, { signal: request.signal })
68
+ })
69
+
70
+ router.post('/:downloadId/abort', async (request): Promise<Response> => {
71
+ const download = getDownload(request.params.downloadId)
72
+ if (download.state.status !== 'downloading') {
73
+ throw new errors.ABORT_NOT_DOWNLOADING(
74
+ `Cannot abort: download status is '${download.state.status}'`,
75
+ )
76
+ }
77
+ download.cancel()
78
+ return new Response(null, { status: 204 })
79
+ })
80
+
81
+ return router
82
+
83
+ function getDownload(downloadId: string): DownloadRequest {
84
+ const download = downloads.get(downloadId)
85
+ if (!download) {
86
+ throw new errors.DOWNLOAD_NOT_FOUND(
87
+ `Download ID not found: ${downloadId}`,
88
+ )
89
+ }
90
+ return download
91
+ }
92
+ }
@@ -0,0 +1,246 @@
1
+ import os from 'node:os'
2
+
3
+ import { IRequestStrict, IttyRouter, type RequestHandler } from 'itty-router'
4
+ import {
5
+ fetch as secretStreamFetch,
6
+ Agent as SecretStreamAgent,
7
+ } from 'secret-stream-http'
8
+ import { Type as T, type Static } from 'typebox'
9
+ import { Compile } from 'typebox/compile'
10
+ import z32 from 'z32'
11
+
12
+ import type { Context } from '../context.js'
13
+ import { errors, StatusError } from '../lib/errors.js'
14
+ import { createEventStreamResponse } from '../lib/event-stream-response.js'
15
+ import { MapShare } from '../lib/map-share.js'
16
+ import { SelfEvictingTimeoutMap } from '../lib/self-evicting-map.js'
17
+ import { addTrailingSlash, timingSafeEqual } from '../lib/utils.js'
18
+ import { localhostOnly } from '../middlewares/localhost-only.js'
19
+ import { parseRequest } from '../middlewares/parse-request.js'
20
+ import {
21
+ MapShareUrls,
22
+ MapShareDeclineReason,
23
+ MapShareState,
24
+ type FetchContext,
25
+ type RouterExternal,
26
+ } from '../types.js'
27
+
28
+ const MapShareCreateRequest = T.Object({
29
+ mapId: T.String({ minLength: 1 }),
30
+ receiverDeviceId: T.String({ minLength: 1 }),
31
+ })
32
+
33
+ export type MapShareCreateParams = Static<typeof MapShareCreateRequest>
34
+
35
+ const LocalMapShareDeclineRequest = T.Object({
36
+ reason: MapShareDeclineReason,
37
+ mapShareUrls: MapShareUrls,
38
+ senderDeviceId: T.String({
39
+ minLength: 1,
40
+ description: 'The ID of the device that is sending the map share',
41
+ }),
42
+ })
43
+
44
+ export type MapShareDeclineParams = Static<typeof LocalMapShareDeclineRequest>
45
+
46
+ const RemoteMapShareDeclineRequest = T.Object({
47
+ reason: MapShareDeclineReason,
48
+ })
49
+
50
+ const CompiledLocalMapShareDeclineRequest = Compile(LocalMapShareDeclineRequest)
51
+ const CompiledRemoteMapShareDeclineRequest = Compile(
52
+ RemoteMapShareDeclineRequest,
53
+ )
54
+
55
+ export function MapSharesRouter(
56
+ { base }: { base: string },
57
+ ctx: Context,
58
+ ): RouterExternal {
59
+ const mapShares = new SelfEvictingTimeoutMap<string, MapShare>()
60
+
61
+ const router = IttyRouter<IRequestStrict, [FetchContext]>({ base })
62
+
63
+ // These routes are only accessible from localhost (local API)
64
+
65
+ router.post(
66
+ '/',
67
+ localhostOnly,
68
+ parseRequest(MapShareCreateRequest),
69
+ async (request) => {
70
+ const { mapId, receiverDeviceId } = request.parsed
71
+ const mapInfo = await ctx.getMapInfo(mapId)
72
+ const mapShare = new MapShare({
73
+ ...mapInfo,
74
+ receiverDeviceId,
75
+ baseUrls: getRemoteBaseUrls(request.url, await ctx.getRemotePort()),
76
+ })
77
+ mapShares.set(mapShare.shareId, mapShare)
78
+ return Response.json(mapShare.state, {
79
+ status: 201,
80
+ headers: {
81
+ Location: new URL(mapShare.shareId, addTrailingSlash(request.url))
82
+ .href,
83
+ },
84
+ })
85
+ },
86
+ )
87
+
88
+ router.get('/', localhostOnly, () => {
89
+ return Array.from(mapShares.values()).map((ms) => ms.state)
90
+ })
91
+
92
+ router.get(
93
+ '/:shareId/events',
94
+ localhostOnly,
95
+ async (request): Promise<Response> => {
96
+ const mapShare = getMapShare(request.params.shareId)
97
+ return createEventStreamResponse(mapShare, { signal: request.signal })
98
+ },
99
+ )
100
+
101
+ router.post(
102
+ '/:shareId/cancel',
103
+ localhostOnly,
104
+ async (request): Promise<Response> => {
105
+ const mapShare = getMapShare(request.params.shareId)
106
+ mapShare.cancel()
107
+ return new Response(null, { status: 204 })
108
+ },
109
+ )
110
+
111
+ // These routes can be accessed by a remote peer, but the peer deviceId must
112
+ // match the receiverDeviceId on the map share
113
+
114
+ const validateRemoteDeviceId = async (
115
+ request: IRequestStrict,
116
+ { remoteDeviceId, isLocalhost }: FetchContext,
117
+ ) => {
118
+ if (isLocalhost) return
119
+ if (!remoteDeviceId) {
120
+ throw new errors.FORBIDDEN()
121
+ }
122
+ const mapShare = getMapShare(request.params.shareId)
123
+ if (!timingSafeEqual(remoteDeviceId, mapShare.state.receiverDeviceId)) {
124
+ throw new errors.FORBIDDEN()
125
+ }
126
+ }
127
+
128
+ router.all('/:shareId', validateRemoteDeviceId)
129
+ router.all('/:shareId/*', validateRemoteDeviceId)
130
+
131
+ router.get('/:shareId', async (request): Promise<MapShareState> => {
132
+ return getMapShare(request.params.shareId).state
133
+ })
134
+
135
+ router.get('/:shareId/download', async (request): Promise<Response> => {
136
+ const mapShare = getMapShare(request.params.shareId)
137
+ const stream = ctx.createMapReadableStream(mapShare.state.mapId)
138
+ return mapShare.downloadResponse(stream)
139
+ })
140
+
141
+ const localDeclineHandler: RequestHandler = async (request) => {
142
+ let parsedBody: Static<typeof LocalMapShareDeclineRequest>
143
+ try {
144
+ const json = await request.json()
145
+ if (!CompiledLocalMapShareDeclineRequest.Check(json)) {
146
+ throw new errors.INVALID_REQUEST()
147
+ }
148
+ parsedBody = json
149
+ } catch (err) {
150
+ if ('status' in (err as object)) throw err
151
+ throw new errors.INVALID_REQUEST()
152
+ }
153
+ const { senderDeviceId, mapShareUrls, reason } = parsedBody
154
+ const remotePublicKey = z32.decode(senderDeviceId)
155
+ const keyPair = ctx.getKeyPair()
156
+ let response: Response | undefined
157
+ // The sharer could have multiple IPs for different network interfaces, and
158
+ // not all of them may be on the same network as us, so try each URL until
159
+ // one works
160
+ for (const mapShareUrl of mapShareUrls) {
161
+ const url = new URL('decline', addTrailingSlash(mapShareUrl))
162
+ try {
163
+ response = (await secretStreamFetch(url, {
164
+ method: 'POST',
165
+ body: JSON.stringify({ reason }),
166
+ signal: request.signal,
167
+ dispatcher: new SecretStreamAgent({ remotePublicKey, keyPair }),
168
+ })) as unknown as Response // Subtle difference bewteen Undici fetch Response and whatwg Response
169
+ break // Exit loop on successful fetch
170
+ } catch (error) {
171
+ if (error instanceof DOMException && error.name === 'AbortError') {
172
+ throw error // Handle abort in caller
173
+ }
174
+ // Otherwise, try the next URL
175
+ }
176
+ }
177
+ if (!response) {
178
+ throw new errors.DECLINE_CANNOT_CONNECT()
179
+ }
180
+ if (!response.ok) {
181
+ // pass through error from sender
182
+ throw new StatusError(response.status, await response.json())
183
+ }
184
+ return new Response(null, { status: 204 })
185
+ }
186
+
187
+ const remoteDeclineHandler: RequestHandler = async (request) => {
188
+ let parsedBody: Static<typeof RemoteMapShareDeclineRequest>
189
+ try {
190
+ const json = await request.json()
191
+ if (!CompiledRemoteMapShareDeclineRequest.Check(json)) {
192
+ throw new errors.INVALID_REQUEST()
193
+ }
194
+ parsedBody = json
195
+ } catch (err) {
196
+ if ('status' in (err as object)) throw err
197
+ throw new errors.INVALID_REQUEST()
198
+ }
199
+ const { reason } = parsedBody
200
+ const mapShare = getMapShare(request.params.shareId)
201
+ mapShare.decline(reason)
202
+ return new Response(null, { status: 204 })
203
+ }
204
+
205
+ router.post(
206
+ '/:shareId/decline',
207
+ async (request, { isLocalhost }): Promise<Response> => {
208
+ if (isLocalhost) {
209
+ return localDeclineHandler(request)
210
+ } else {
211
+ return remoteDeclineHandler(request)
212
+ }
213
+ },
214
+ )
215
+
216
+ return router
217
+
218
+ function getMapShare(shareId: string) {
219
+ const mapShare = mapShares.get(shareId)
220
+ if (!mapShare) {
221
+ throw new errors.MAP_SHARE_NOT_FOUND(`Map share ID not found: ${shareId}`)
222
+ }
223
+ return mapShare
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Get the base URLs for downloads for all non-internal IPv4 addresses of the machine
229
+ */
230
+ function getRemoteBaseUrls(requestUrl: string, remotePort: number): string[] {
231
+ requestUrl = addTrailingSlash(requestUrl)
232
+ const interfaces = os.networkInterfaces()
233
+ const baseUrls: string[] = []
234
+ for (const iface of Object.values(interfaces)) {
235
+ if (!iface) continue
236
+ for (const addr of iface) {
237
+ if (addr.family === 'IPv4' && !addr.internal) {
238
+ const url = new URL(requestUrl)
239
+ url.hostname = addr.address
240
+ url.port = remotePort.toString()
241
+ baseUrls.push(url.toString())
242
+ }
243
+ }
244
+ }
245
+ return baseUrls
246
+ }
@@ -0,0 +1,146 @@
1
+ import { IRequestStrict, IttyRouter, type RequestHandler } from 'itty-router'
2
+ import Mutex from 'p-mutex'
3
+ import { createServer as createSmpServer } from 'styled-map-package/server'
4
+
5
+ import type { Context } from '../context.js'
6
+ import {
7
+ CUSTOM_MAP_ID,
8
+ DEFAULT_MAP_ID,
9
+ FALLBACK_MAP_ID,
10
+ } from '../lib/constants.js'
11
+ import { errors } from '../lib/errors.js'
12
+ import { addTrailingSlash, noop } from '../lib/utils.js'
13
+
14
+ type MapRequest = IRequestStrict & {
15
+ params: {
16
+ mapId: string
17
+ }
18
+ }
19
+
20
+ export function MapsRouter({ base = '/' }, ctx: Context) {
21
+ base = addTrailingSlash(base)
22
+ const uploadMutexes = new Map<string, Mutex>()
23
+
24
+ const smpServer = createSmpServer({
25
+ base: `${base}:mapId/`,
26
+ })
27
+
28
+ const router = IttyRouter<IRequestStrict>({ base })
29
+
30
+ router.get<MapRequest>(`/:mapId/info`, async (request) => {
31
+ const info = await ctx.getMapInfo(request.params.mapId)
32
+ return {
33
+ created: info.mapCreated,
34
+ size: info.estimatedSizeBytes,
35
+ name: info.mapName,
36
+ }
37
+ })
38
+
39
+ const uploadHandler: RequestHandler<MapRequest> = async (request) => {
40
+ const writable = ctx.createMapWritableStream(request.params.mapId)
41
+ if (!request.body) {
42
+ throw new errors.INVALID_REQUEST('Request body is required')
43
+ }
44
+ await request.body.pipeTo(writable)
45
+ }
46
+
47
+ router.put<MapRequest>('/:mapId', async (request) => {
48
+ // Only allow uploading to the custom map ID for now
49
+ if (
50
+ request.params.mapId === DEFAULT_MAP_ID ||
51
+ request.params.mapId === FALLBACK_MAP_ID
52
+ ) {
53
+ throw new errors.FORBIDDEN(
54
+ `Uploading to map ID "${request.params.mapId}" is not allowed`,
55
+ )
56
+ } else if (request.params.mapId !== CUSTOM_MAP_ID) {
57
+ throw new errors.MAP_NOT_FOUND(`Map not found: ${request.params.mapId}`)
58
+ }
59
+ if (!request.body) {
60
+ throw new errors.INVALID_REQUEST('Request body is required')
61
+ }
62
+ // Get or create a mutex for this mapId to ensure sequential uploads
63
+ let mutex = uploadMutexes.get(request.params.mapId)
64
+ if (!mutex) {
65
+ mutex = new Mutex()
66
+ uploadMutexes.set(request.params.mapId, mutex)
67
+ }
68
+ await mutex.withLock(() => uploadHandler(request))
69
+ return new Response(null, { status: 200 })
70
+ })
71
+
72
+ router.delete<MapRequest>('/:mapId', async (request) => {
73
+ // Only allow deleting the custom map ID
74
+ if (
75
+ request.params.mapId === DEFAULT_MAP_ID ||
76
+ request.params.mapId === FALLBACK_MAP_ID
77
+ ) {
78
+ throw new errors.FORBIDDEN(
79
+ `Deleting the map ID "${request.params.mapId}" is not allowed`,
80
+ )
81
+ } else if (request.params.mapId !== CUSTOM_MAP_ID) {
82
+ throw new errors.MAP_NOT_FOUND(`Map not found: ${request.params.mapId}`)
83
+ }
84
+ // Use mutex to wait for any active uploads to complete before deleting
85
+ let mutex = uploadMutexes.get(request.params.mapId)
86
+ if (!mutex) {
87
+ mutex = new Mutex()
88
+ uploadMutexes.set(request.params.mapId, mutex)
89
+ }
90
+ await mutex.withLock(() => ctx.deleteMap(request.params.mapId))
91
+ return new Response(null, { status: 204 })
92
+ })
93
+
94
+ router.all(`/:mapId/*`, async (request) => {
95
+ if (request.params.mapId === DEFAULT_MAP_ID) {
96
+ return defaultMapHandler(request)
97
+ }
98
+ // Get the reader first - this throws MAP_NOT_FOUND for unknown map IDs
99
+ const reader = await ctx.getReader(request.params.mapId)
100
+ try {
101
+ return await smpServer.fetch(request, reader)
102
+ } catch (err) {
103
+ // Convert generic 404 from smpServer to RESOURCE_NOT_FOUND
104
+ if (err instanceof Error && 'status' in err && err.status === 404) {
105
+ throw new errors.RESOURCE_NOT_FOUND()
106
+ }
107
+ throw err
108
+ }
109
+ })
110
+
111
+ // Special handler for the default map ID that tries to serve a custom map
112
+ // if available, otherwise falls back to the online style or bundled fallback
113
+ const defaultMapHandler: RequestHandler = async (request) => {
114
+ const defaultOnlineStyleUrl = ctx.getDefaultOnlineStyleUrl()
115
+ const styleUrls = [
116
+ new URL(`../${CUSTOM_MAP_ID}/style.json`, request.url),
117
+ defaultOnlineStyleUrl,
118
+ new URL(`../${FALLBACK_MAP_ID}/style.json`, request.url),
119
+ ]
120
+
121
+ for (const url of styleUrls) {
122
+ let response: Response | void
123
+ if (url === defaultOnlineStyleUrl) {
124
+ response = await fetch(url).catch(noop)
125
+ } else {
126
+ // No need to go through the networking stack for local requests
127
+ response = await router.fetch(new Request(url)).catch(noop)
128
+ }
129
+ response?.body?.cancel() // Close the connection
130
+ if (response && response.ok) {
131
+ return new Response(null, {
132
+ status: 302,
133
+ headers: {
134
+ location: url.toString(),
135
+ 'access-control-allow-origin': '*',
136
+ 'cache-control': 'no-cache',
137
+ },
138
+ })
139
+ }
140
+ }
141
+
142
+ throw new errors.MAP_NOT_FOUND('No available map style found')
143
+ }
144
+
145
+ return router
146
+ }
@@ -0,0 +1,37 @@
1
+ import { json, Router, type IRequestStrict } from 'itty-router'
2
+
3
+ import type { Context } from '../context.js'
4
+ import { error } from '../lib/errors.js'
5
+ import { localhostOnly } from '../middlewares/localhost-only.js'
6
+ import type { FetchContext, RouterExternal } from '../types.js'
7
+ import { DownloadsRouter } from './downloads.js'
8
+ import { MapSharesRouter } from './map-shares.js'
9
+ import { MapsRouter } from './maps.js'
10
+
11
+ const MAPS_BASE = '/maps/'
12
+ const MAP_SHARES_BASE = '/mapShares/'
13
+ const DOWNLOADS_BASE = '/downloads/'
14
+
15
+ export function RootRouter({ base = '/' }, ctx: Context): RouterExternal {
16
+ const router = Router<IRequestStrict, [FetchContext]>({
17
+ base,
18
+ // The `error` handler will send a response with the status code from any
19
+ // thrown StatusError, or a 500 for any other errors.
20
+ catch: (err) => error(err),
21
+ // Sends a 404 response for any requests that don't match a route, and for
22
+ // any request handlers that return JSON will send a JSON response.
23
+ finally: [(response) => response ?? error(404), json],
24
+ })
25
+
26
+ const mapsRouter = MapsRouter({ base: MAPS_BASE }, ctx)
27
+ const downloadsRouter = DownloadsRouter({ base: DOWNLOADS_BASE }, ctx)
28
+ const mapSharesRouter = MapSharesRouter({ base: MAP_SHARES_BASE }, ctx)
29
+
30
+ router.all(`${MAPS_BASE}*`, localhostOnly, mapsRouter.fetch)
31
+ router.all(`${DOWNLOADS_BASE}*`, localhostOnly, downloadsRouter.fetch)
32
+ // Some map share routes are remote-accessible - localhostOnly is applied in
33
+ // the map shares router where needed
34
+ router.all(`${MAP_SHARES_BASE}*`, mapSharesRouter.fetch)
35
+
36
+ return router
37
+ }
package/src/types.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { RequestLike } from 'itty-router'
2
+ import { Type as T, type Static } from 'typebox'
3
+
4
+ export const MapShareDeclineReason = T.Union([
5
+ T.Literal('disk_full', {
6
+ description:
7
+ "The map share was declined because the receiver's disk is full",
8
+ }),
9
+ T.Literal('user_rejected', {
10
+ description: 'The map share was declined by the user',
11
+ }),
12
+ T.String({
13
+ minLength: 1,
14
+ description: 'Other reason for declining the map share',
15
+ }),
16
+ ])
17
+
18
+ const MapShareStateUpdate = T.Union([
19
+ T.Object({
20
+ status: T.Literal('pending', {
21
+ description: 'Map share is awaiting a response',
22
+ }),
23
+ }),
24
+ T.Object({
25
+ status: T.Literal('declined', {
26
+ description: 'Map share has been declined',
27
+ }),
28
+ reason: MapShareDeclineReason,
29
+ }),
30
+ T.Object({
31
+ status: T.Literal('downloading', {
32
+ description: 'Map share is currently being downloaded',
33
+ }),
34
+ bytesDownloaded: T.Number({
35
+ description:
36
+ 'Total bytes downloaded so far (compare with estimatedSizeBytes for progress)',
37
+ }),
38
+ }),
39
+ T.Object({
40
+ status: T.Literal('canceled', {
41
+ description: 'Map share has been canceled (by the sharer)',
42
+ }),
43
+ }),
44
+ T.Object({
45
+ status: T.Literal('aborted', {
46
+ description: 'Map share download was aborted (by the receiver)',
47
+ }),
48
+ }),
49
+ T.Object({
50
+ status: T.Literal('completed', { description: 'Map has been downloaded' }),
51
+ }),
52
+ T.Object({
53
+ status: T.Literal('error', {
54
+ description: 'An error occurred while downloading',
55
+ }),
56
+ error: T.Object(
57
+ {
58
+ message: T.String({ description: 'Error message' }),
59
+ code: T.String({ description: 'Error code' }),
60
+ },
61
+ {
62
+ description: 'Error that occurred while receiving the map share',
63
+ },
64
+ ),
65
+ }),
66
+ ])
67
+
68
+ export type MapShareStateUpdate = Static<typeof MapShareStateUpdate>
69
+ export type MapShareStatus = Static<typeof MapShareStateUpdate>['status']
70
+
71
+ export type DownloadStateUpdate = Extract<
72
+ MapShareStateUpdate,
73
+ { status: 'downloading' | 'completed' | 'error' | 'canceled' | 'aborted' }
74
+ >
75
+
76
+ export const MapShareUrls = T.Array(T.String({ format: 'uri' }), {
77
+ minItems: 1,
78
+ description:
79
+ 'List of map share URLs (for each network interface of the sharer)',
80
+ })
81
+ export const ShareId = T.String({
82
+ minLength: 1,
83
+ description: 'The ID of the map share',
84
+ })
85
+ export type ShareId = Static<typeof ShareId>
86
+ export const EstimatedSizeBytes = T.Number({
87
+ description: 'Estimated size of the map data in bytes',
88
+ })
89
+
90
+ const MapInfo = T.Object({
91
+ mapId: T.String({ description: 'The ID of the map' }),
92
+ mapName: T.String({ description: 'The name of the map' }),
93
+ estimatedSizeBytes: EstimatedSizeBytes,
94
+ bounds: T.ReadonlyType(
95
+ T.Tuple([T.Number(), T.Number(), T.Number(), T.Number()], {
96
+ description: 'The bounding box of the map data',
97
+ }),
98
+ ),
99
+ minzoom: T.Number({ description: 'The minimum zoom level of the map data' }),
100
+ maxzoom: T.Number({ description: 'The maximum zoom level of the map data' }),
101
+ mapCreated: T.Number({
102
+ description: 'Timestamp (ms since epoch) when the map was created',
103
+ }),
104
+ })
105
+
106
+ const MapShareBase = T.Intersect([
107
+ T.Object({
108
+ receiverDeviceId: T.String({
109
+ description: 'The ID of the device that can receive the map share',
110
+ }),
111
+ shareId: ShareId,
112
+ mapShareUrls: MapShareUrls,
113
+ mapShareCreated: T.Number({
114
+ description: 'Timestamp (ms since epoch) when the map share was created',
115
+ }),
116
+ }),
117
+ MapInfo,
118
+ ])
119
+
120
+ export const MapShareState = T.Intersect([MapShareBase, MapShareStateUpdate])
121
+
122
+ export type MapShareState = DistributiveIntersection<
123
+ Static<typeof MapShareBase>,
124
+ Static<typeof MapShareStateUpdate>
125
+ >
126
+
127
+ export type MapInfo = Static<typeof MapInfo>
128
+
129
+ export type FetchContext = {
130
+ isLocalhost?: boolean
131
+ remoteDeviceId?: string
132
+ }
133
+
134
+ export type DistributiveIntersection<Base, Union> = Union extends unknown
135
+ ? Base & Union
136
+ : never
137
+
138
+ export type DistributeProperty<T, K extends keyof T> = T[K] extends infer V
139
+ ? V extends T[K]
140
+ ? { [P in K]: V } & Omit<T, K>
141
+ : never
142
+ : never
143
+
144
+ /**
145
+ * External router returned by create router functions. Ensures routers are
146
+ * called with the necessary fetch context
147
+ */
148
+ export type RouterExternal = {
149
+ fetch: (request: RequestLike, context: FetchContext) => Promise<any>
150
+ }
151
+
152
+ export type BBox = Readonly<[number, number, number, number]>