@tetherto/mdk-app-runtime 0.0.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +106 -0
- package/bin/run-app.js +118 -0
- package/index.js +14 -0
- package/lib/client.js +297 -0
- package/lib/manifest.js +75 -0
- package/lib/plugin-loader.js +216 -0
- package/lib/protocol.js +184 -0
- package/lib/runtime.js +583 -0
- package/package.json +57 -1
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Wire protocol between an app runtime (server) and its callers (the Gateway,
|
|
4
|
+
// tests) -- see lib/runtime.js and lib/client.js.
|
|
5
|
+
//
|
|
6
|
+
// Transport: @hyperswarm/rpc (HyperDHT + protomux-rpc), one app runtime = one
|
|
7
|
+
// RPC server key. Everything on the wire is JSON in Buffers.
|
|
8
|
+
//
|
|
9
|
+
// Unary routes -- `app.invoke`
|
|
10
|
+
// A regular @hyperswarm/rpc request/response.
|
|
11
|
+
// request : { v:1, routeId, req:{ params, query, body, headers }, identity }
|
|
12
|
+
// response: { v:1, ok:true, result }
|
|
13
|
+
// or { v:1, ok:false, status, code, message }
|
|
14
|
+
// The responder never throws: every failure is an `ok:false` response, so a
|
|
15
|
+
// rejected `rpc.request()` always means a transport failure.
|
|
16
|
+
//
|
|
17
|
+
// Stream routes -- `app.stream`
|
|
18
|
+
// @hyperswarm/rpc 3.x has no streaming request API:
|
|
19
|
+
// `request()` / `event()` are strictly one message in, one message out. So
|
|
20
|
+
// streaming rides on the *same* authenticated HyperDHT connection through a
|
|
21
|
+
// protomux channel (the mux the RPC layer already uses, reachable as
|
|
22
|
+
// `rpc.mux` on both sides). Each stream uses a dedicated connection with a
|
|
23
|
+
// single channel (protocol `app.stream`, null id), so closing either side
|
|
24
|
+
// tears the whole thing down and there is no multiplexing state to leak.
|
|
25
|
+
// The firewall that guards `app.invoke` guards the connection, hence the
|
|
26
|
+
// stream, too.
|
|
27
|
+
//
|
|
28
|
+
// Channel messages (fixed order, index = message type, raw buffers):
|
|
29
|
+
// 0 REQUEST client -> server same JSON as an `app.invoke` request
|
|
30
|
+
// 1 HEAD server -> client JSON { statusCode, headers }
|
|
31
|
+
// 2 CHUNK server -> client raw body bytes (any number)
|
|
32
|
+
// 3 END server -> client empty; body complete
|
|
33
|
+
// 4 ERROR server -> client JSON { v:1, ok:false, status, code, message }
|
|
34
|
+
// protocol-level failure before any HEAD
|
|
35
|
+
// (unknown route, version mismatch, ...)
|
|
36
|
+
// Either side closing the channel (or the connection dropping) aborts the
|
|
37
|
+
// stream: the server's `raw` emits 'close', the client's Readable errors out
|
|
38
|
+
// unless END was already received.
|
|
39
|
+
//
|
|
40
|
+
// Backpressure: there is no credit or pause message, and the transport under
|
|
41
|
+
// protomux (UDX) does not push back on a reader that stops reading, so the
|
|
42
|
+
// client cannot slow the server down. Instead:
|
|
43
|
+
// - client: each stream's unread bytes are capped (`maxStreamBufferBytes` on
|
|
44
|
+
// createRuntimeClient, default 16 MiB). Past the cap the Readable is
|
|
45
|
+
// destroyed with ERR_APP_STREAM_OVERFLOW and the channel and its connection
|
|
46
|
+
// are closed, so the runtime stops sending (the handler's `raw` emits
|
|
47
|
+
// 'close'). A Gateway piping to a slow HTTP client therefore holds at most
|
|
48
|
+
// that much per stream.
|
|
49
|
+
// - server: `raw.write()` returns false while the connection's outgoing buffer
|
|
50
|
+
// is full and `raw` emits 'drain' once it has flushed, so a handler that
|
|
51
|
+
// honours write()'s return value (or uses stream.pipeline) does not queue
|
|
52
|
+
// unbounded data in the runtime process.
|
|
53
|
+
//
|
|
54
|
+
// Errors: a message starting with `ERR_` is safe to return as-is; anything else
|
|
55
|
+
// is masked with a generic status text. Status is `err.statusCode` when it is
|
|
56
|
+
// an integer >= 400, else 400 for an `ERR_` error and 500 for anything else.
|
|
57
|
+
|
|
58
|
+
const http = require('http')
|
|
59
|
+
|
|
60
|
+
const PROTOCOL_VERSION = 1
|
|
61
|
+
|
|
62
|
+
const METHODS = Object.freeze({
|
|
63
|
+
INVOKE: 'app.invoke',
|
|
64
|
+
STREAM: 'app.stream'
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Index of each message on the `app.stream` channel.
|
|
68
|
+
const STREAM_MESSAGES = Object.freeze({
|
|
69
|
+
REQUEST: 0,
|
|
70
|
+
HEAD: 1,
|
|
71
|
+
CHUNK: 2,
|
|
72
|
+
END: 3,
|
|
73
|
+
ERROR: 4
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const ERR = Object.freeze({
|
|
77
|
+
ROUTE_NOT_FOUND: 'ERR_APP_ROUTE_NOT_FOUND',
|
|
78
|
+
PROTOCOL_VERSION: 'ERR_APP_PROTOCOL_VERSION',
|
|
79
|
+
BAD_REQUEST: 'ERR_APP_BAD_REQUEST',
|
|
80
|
+
ROUTE_IS_STREAM: 'ERR_APP_ROUTE_IS_STREAM',
|
|
81
|
+
ROUTE_NOT_STREAM: 'ERR_APP_ROUTE_NOT_STREAM',
|
|
82
|
+
REQUEST_FAILED: 'ERR_APP_REQUEST_FAILED',
|
|
83
|
+
RUNTIME_UNAVAILABLE: 'ERR_APP_RUNTIME_UNAVAILABLE',
|
|
84
|
+
RUNTIME_TIMEOUT: 'ERR_APP_RUNTIME_TIMEOUT',
|
|
85
|
+
REQUEST_ABORTED: 'ERR_APP_REQUEST_ABORTED',
|
|
86
|
+
STREAM_OVERFLOW: 'ERR_APP_STREAM_OVERFLOW'
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
function encode (obj) {
|
|
90
|
+
return Buffer.from(JSON.stringify(obj))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function decode (buf) {
|
|
94
|
+
return JSON.parse(Buffer.from(buf).toString('utf8'))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildRequest (routeId, req, identity) {
|
|
98
|
+
const r = req || {}
|
|
99
|
+
return {
|
|
100
|
+
v: PROTOCOL_VERSION,
|
|
101
|
+
routeId,
|
|
102
|
+
req: { params: r.params, query: r.query, body: r.body, headers: r.headers },
|
|
103
|
+
identity: identity === undefined ? null : identity
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function okResponse (result) {
|
|
108
|
+
return { v: PROTOCOL_VERSION, ok: true, result }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function errorResponse (status, code, message) {
|
|
112
|
+
return { v: PROTOCOL_VERSION, ok: false, status, code, message }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isSafeError (err) {
|
|
116
|
+
return !!(err && typeof err.message === 'string' && err.message.startsWith('ERR_'))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _statusOf (err) {
|
|
120
|
+
if (Number.isInteger(err && err.statusCode) && err.statusCode >= 400) return err.statusCode
|
|
121
|
+
return isSafeError(err) ? 400 : 500
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What a handler/protocol error looks like on the wire (the `ok:false` shape).
|
|
125
|
+
// Safe (`ERR_*`) messages pass through; everything else becomes a generic
|
|
126
|
+
// status text so internals never leak to a caller.
|
|
127
|
+
function toErrorResponse (err) {
|
|
128
|
+
const status = _statusOf(err)
|
|
129
|
+
if (isSafeError(err)) {
|
|
130
|
+
const m = /^ERR_[A-Z0-9_]+/.exec(err.message)
|
|
131
|
+
return errorResponse(status, m ? m[0] : ERR.REQUEST_FAILED, err.message)
|
|
132
|
+
}
|
|
133
|
+
return errorResponse(status, ERR.REQUEST_FAILED, http.STATUS_CODES[status] || 'Bad Request')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// An error carrying an explicit status + code, for protocol-level failures.
|
|
137
|
+
function protocolError (status, code, detail) {
|
|
138
|
+
const err = new Error(detail ? `${code}: ${detail}` : code)
|
|
139
|
+
err.statusCode = status
|
|
140
|
+
err.code = code
|
|
141
|
+
return err
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Rebuild an Error from an `ok:false` response (client side).
|
|
145
|
+
// `remote` marks an error the runtime already handled (and logged, if it was a
|
|
146
|
+
// bug): the caller only relays it.
|
|
147
|
+
function fromErrorResponse (res) {
|
|
148
|
+
const err = new Error(res.message || res.code || 'Bad Request')
|
|
149
|
+
err.statusCode = Number.isInteger(res.status) ? res.status : 400
|
|
150
|
+
err.code = res.code || ERR.REQUEST_FAILED
|
|
151
|
+
err.remote = true
|
|
152
|
+
return err
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validates a decoded request envelope; returns null when fine, else an Error.
|
|
156
|
+
function checkRequest (msg) {
|
|
157
|
+
if (!msg || typeof msg !== 'object') {
|
|
158
|
+
return protocolError(400, ERR.BAD_REQUEST, 'request must be an object')
|
|
159
|
+
}
|
|
160
|
+
if (msg.v !== PROTOCOL_VERSION) {
|
|
161
|
+
return protocolError(400, ERR.PROTOCOL_VERSION, `expected ${PROTOCOL_VERSION}, got ${msg.v}`)
|
|
162
|
+
}
|
|
163
|
+
if (typeof msg.routeId !== 'string' || !msg.routeId) {
|
|
164
|
+
return protocolError(400, ERR.BAD_REQUEST, 'routeId is required')
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
PROTOCOL_VERSION,
|
|
171
|
+
METHODS,
|
|
172
|
+
STREAM_MESSAGES,
|
|
173
|
+
ERR,
|
|
174
|
+
encode,
|
|
175
|
+
decode,
|
|
176
|
+
buildRequest,
|
|
177
|
+
okResponse,
|
|
178
|
+
errorResponse,
|
|
179
|
+
toErrorResponse,
|
|
180
|
+
fromErrorResponse,
|
|
181
|
+
isSafeError,
|
|
182
|
+
protocolError,
|
|
183
|
+
checkRequest
|
|
184
|
+
}
|