Skip to content

Commit 67f42b5

Browse files
authored
Merge pull request #3 from digidem/feat/map-sharing
feat: add map sharing and downloads
2 parents 47499d7 + 2d8ea22 commit 67f42b5

24 files changed

Lines changed: 12502 additions & 8536 deletions

README.md

Lines changed: 609 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 8838 additions & 8430 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 78 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,80 @@
11
{
2-
"name": "@comapeo/map-server",
3-
"version": "1.0.0",
4-
"type": "module",
5-
"exports": {
6-
".": {
7-
"types": "./dist/index.d.ts",
8-
"default": "./dist/index.js"
9-
}
10-
},
11-
"scripts": {
12-
"test": "vitest run",
13-
"format": "prettier --write .",
14-
"lint": "eslint",
15-
"types": "tsc --noEmit",
16-
"prepare": "husky",
17-
"prepack": "tsc -p tsconfig.npm.json"
18-
},
19-
"keywords": [],
20-
"author": "",
21-
"license": "MIT",
22-
"description": "",
23-
"devDependencies": {
24-
"@eslint/js": "^9.39.1",
25-
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
26-
"@tsconfig/node18": "^18.2.6",
27-
"@types/bogon": "^1.0.2",
28-
"@types/node": "^20.19.26",
29-
"@vitest/coverage-v8": "^4.0.17",
30-
"bogon": "^1.1.0",
31-
"eslint": "^9.39.1",
32-
"eventsource-client": "^1.2.0",
33-
"globals": "^16.5.0",
34-
"husky": "^9.1.7",
35-
"lint-staged": "^16.2.7",
36-
"msw": "^2.12.4",
37-
"prettier": "^3.7.4",
38-
"type-fest": "^5.3.1",
39-
"typescript": "^5.9.3",
40-
"vitest": "^4.0.15"
41-
},
42-
"prettier": {
43-
"semi": false,
44-
"singleQuote": true,
45-
"useTabs": true,
46-
"importOrderSeparation": true,
47-
"importOrder": [
48-
"^node:",
49-
"^@?\\w",
50-
"^[./]"
51-
],
52-
"plugins": [
53-
"@trivago/prettier-plugin-sort-imports"
54-
]
55-
},
56-
"lint-staged": {
57-
"*.js": "eslint --cache --fix",
58-
"*.{js,css,md}": "prettier --write"
59-
},
60-
"dependencies": {
61-
"@whatwg-node/server": "^0.10.17",
62-
"itty-router": "^5.0.22",
63-
"p-defer": "^4.0.1",
64-
"secret-stream-http": "^1.0.1",
65-
"styled-map-package": "^4.0.1",
66-
"typebox": "^1.0.61",
67-
"typed-event-target": "^3.4.0",
68-
"z32": "^1.1.0"
69-
},
70-
"files": [
71-
"src/",
72-
"dist/"
73-
]
2+
"name": "@comapeo/map-server",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"exports": {
6+
".": {
7+
"types": "./dist/index.d.ts",
8+
"default": "./dist/index.js"
9+
}
10+
},
11+
"scripts": {
12+
"test": "vitest run",
13+
"format": "prettier --write .",
14+
"lint": "eslint",
15+
"types": "tsc --noEmit",
16+
"prepare": "husky",
17+
"prepack": "tsc -p tsconfig.npm.json",
18+
"postinstall": "patch-package"
19+
},
20+
"keywords": [],
21+
"author": "",
22+
"license": "MIT",
23+
"description": "",
24+
"devDependencies": {
25+
"@eslint/js": "^9.39.1",
26+
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
27+
"@tsconfig/node18": "^18.2.6",
28+
"@types/bogon": "^1.0.2",
29+
"@types/node": "^20.19.26",
30+
"@vitest/coverage-v8": "^4.0.17",
31+
"bogon": "^1.1.0",
32+
"eslint": "^9.39.1",
33+
"eventsource-client": "^1.2.0",
34+
"globals": "^16.5.0",
35+
"husky": "^9.1.7",
36+
"ky": "1.7.5",
37+
"lint-staged": "^16.2.7",
38+
"msw": "^2.12.4",
39+
"patch-package": "^8.0.1",
40+
"prettier": "^3.7.4",
41+
"type-fest": "^5.3.1",
42+
"typescript": "^5.9.3",
43+
"vitest": "^4.0.15"
44+
},
45+
"prettier": {
46+
"semi": false,
47+
"singleQuote": true,
48+
"useTabs": true,
49+
"importOrderSeparation": true,
50+
"importOrder": [
51+
"^node:",
52+
"^@?\\w",
53+
"^[./]"
54+
],
55+
"plugins": [
56+
"@trivago/prettier-plugin-sort-imports"
57+
]
58+
},
59+
"lint-staged": {
60+
"*.js": "eslint --cache --fix",
61+
"*.{js,css,md}": "prettier --write"
62+
},
63+
"dependencies": {
64+
"@whatwg-node/server": "^0.10.17",
65+
"itty-router": "^5.0.22",
66+
"p-defer": "^4.0.1",
67+
"secret-stream-http": "^1.0.1",
68+
"styled-map-package": "^4.0.1",
69+
"typebox": "^1.0.61",
70+
"typed-event-target": "^3.4.0",
71+
"z32": "^1.1.0"
72+
},
73+
"bundleDependencies": [
74+
"@whatwg-node/server"
75+
],
76+
"files": [
77+
"src/",
78+
"dist/"
79+
]
7480
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
diff --git a/node_modules/@whatwg-node/server/cjs/createServerAdapter.js b/node_modules/@whatwg-node/server/cjs/createServerAdapter.js
2+
index e24db6e..980d0d8 100644
3+
--- a/node_modules/@whatwg-node/server/cjs/createServerAdapter.js
4+
+++ b/node_modules/@whatwg-node/server/cjs/createServerAdapter.js
5+
@@ -4,7 +4,6 @@ exports.createServerAdapter = createServerAdapter;
6+
const tslib_1 = require("tslib");
7+
const instrumentation_1 = require("@envelop/instrumentation");
8+
const disposablestack_1 = require("@whatwg-node/disposablestack");
9+
-const DefaultFetchAPI = tslib_1.__importStar(require("@whatwg-node/fetch"));
10+
const promise_helpers_1 = require("@whatwg-node/promise-helpers");
11+
const utils_js_1 = require("./utils.js");
12+
const uwebsockets_js_1 = require("./uwebsockets.js");
13+
@@ -21,7 +20,6 @@ const EMPTY_OBJECT = {};
14+
function createServerAdapter(serverAdapterBaseObject, options) {
15+
const useSingleWriteHead = options?.__useSingleWriteHead == null ? true : options.__useSingleWriteHead;
16+
const fetchAPI = {
17+
- ...DefaultFetchAPI,
18+
...options?.fetchAPI,
19+
};
20+
const useCustomAbortCtrl = options?.__useCustomAbortCtrl == null
21+
diff --git a/node_modules/@whatwg-node/server/esm/createServerAdapter.js b/node_modules/@whatwg-node/server/esm/createServerAdapter.js
22+
index 941f115..0815d9a 100644
23+
--- a/node_modules/@whatwg-node/server/esm/createServerAdapter.js
24+
+++ b/node_modules/@whatwg-node/server/esm/createServerAdapter.js
25+
@@ -1,6 +1,5 @@
26+
import { chain, getInstrumented } from '@envelop/instrumentation';
27+
import { AsyncDisposableStack, DisposableSymbols } from '@whatwg-node/disposablestack';
28+
-import * as DefaultFetchAPI from '@whatwg-node/fetch';
29+
import { handleMaybePromise, unfakePromise } from '@whatwg-node/promise-helpers';
30+
import { completeAssign, createCustomAbortControllerSignal, ensureDisposableStackRegisteredForTerminateEvents, handleAbortSignalAndPromiseResponse, handleErrorFromRequestHandler, isFetchEvent, isNodeRequest, isolateObject, isPromise, isRequestInit, isServerResponse, iterateAsyncVoid, normalizeNodeRequest, sendNodeResponse, } from './utils.js';
31+
import { fakePromise, getRequestFromUWSRequest, isUWSResponse, sendResponseToUwsOpts, } from './uwebsockets.js';
32+
@@ -17,7 +16,6 @@ const EMPTY_OBJECT = {};
33+
function createServerAdapter(serverAdapterBaseObject, options) {
34+
const useSingleWriteHead = options?.__useSingleWriteHead == null ? true : options.__useSingleWriteHead;
35+
const fetchAPI = {
36+
- ...DefaultFetchAPI,
37+
...options?.fetchAPI,
38+
};
39+
const useCustomAbortCtrl = options?.__useCustomAbortCtrl == null
40+
diff --git a/node_modules/@whatwg-node/server/esm/utils.js b/node_modules/@whatwg-node/server/esm/utils.js
41+
index 237a6e1..5cbe472 100644
42+
--- a/node_modules/@whatwg-node/server/esm/utils.js
43+
+++ b/node_modules/@whatwg-node/server/esm/utils.js
44+
@@ -296,14 +296,25 @@ export function sendNodeResponse(fetchResponse, serverResponse, nodeRequest, __u
45+
function sendReadableStream(nodeRequest, serverResponse, readableStream) {
46+
const reader = readableStream.getReader();
47+
nodeRequest?.once?.('error', err => {
48+
- reader.cancel(err);
49+
+ reader.cancel(err).catch(() => {});
50+
});
51+
function pump() {
52+
return reader
53+
.read()
54+
.then(({ done, value }) => done
55+
? endResponse(serverResponse)
56+
- : handleMaybePromise(() => safeWrite(value, serverResponse), pump));
57+
+ : handleMaybePromise(() => safeWrite(value, serverResponse), pump))
58+
+ .catch(err => {
59+
+ // If stream was aborted (e.g., via cancel()), destroy the socket with RST
60+
+ if (err?.name === 'AbortError') {
61+
+ if (!serverResponse.destroyed) {
62+
+ serverResponse.destroy();
63+
+ }
64+
+ return;
65+
+ }
66+
+ // For other errors, re-throw to be handled by the request handler
67+
+ throw err;
68+
+ });
69+
}
70+
return pump();
71+
}

src/context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ export class Context {
102102
this.#mapReaders.set(mapId, Promise.resolve(reader))
103103
return Promise.resolve(reader)
104104
}
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+
}
105114
/**
106115
* Creates a writable stream to write map data to the specified map ID.
107116
* The data is first written to a temporary file, and once the stream is closed,

src/lib/download-request.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 { DownloadCreateRequest } 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+
type DownloadRequestState = DownloadStateUpdate &
14+
Omit<DownloadCreateRequest, 'mapShareUrls'> & { downloadId: string }
15+
16+
export class DownloadRequest extends TypedEventTarget<
17+
InstanceType<typeof StateUpdateEvent<DownloadStateUpdate>>
18+
> {
19+
#state: DownloadRequestState
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 }: DownloadCreateRequest,
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+
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 (err) {
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+
remotePublicKey,
103+
keyPair,
104+
}: {
105+
mapShareUrls: string[]
106+
stream: WritableStream<Uint8Array>
107+
remotePublicKey: Uint8Array
108+
keyPair: { publicKey: Uint8Array; secretKey: Uint8Array }
109+
}) {
110+
const downloadUrls = mapShareUrls.map(
111+
(baseUrl) => new URL('download', addTrailingSlash(baseUrl)),
112+
)
113+
const response = await secretStreamFetch(downloadUrls, {
114+
dispatcher: this.#dispatcher,
115+
})
116+
if (!response.body) {
117+
throw new errors.DOWNLOAD_ERROR('Could not connect to map share sender')
118+
}
119+
if (!response.ok) {
120+
throw new StatusError(response.status, await response.json())
121+
}
122+
if (this.#abortController.signal.aborted) {
123+
response.body.cancel().catch(noop)
124+
throw new DOMException('Download aborted', 'AbortError')
125+
}
126+
await response.body.pipeThrough(this.#transform).pipeTo(stream, {
127+
signal: this.#abortController.signal,
128+
})
129+
this.#updateState({ status: 'completed' })
130+
}
131+
132+
get state() {
133+
return this.#state
134+
}
135+
136+
cancel() {
137+
this.#abortController.abort()
138+
}
139+
140+
#updateState(update: DownloadStateUpdate) {
141+
this.#state = { ...this.#state, ...update }
142+
this.dispatchEvent(new StateUpdateEvent(update))
143+
}
144+
}

0 commit comments

Comments
 (0)