@tileflow/react-native 0.0.0-bootstrap.0 → 0.1.0-alpha.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/README.md +216 -4
- package/TileflowNativeAdmission.podspec +34 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +144 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +89 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionCatalog.kt +137 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionDocumentScope.kt +22 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +335 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionModuleProvider.kt +68 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionOkHttpNetwork.kt +86 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionProviderLease.kt +56 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionTypes.kt +85 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionUrl.kt +100 -0
- package/android/src/main/java/dev/tileflow/reactnative/HandlerAdmissionScheduler.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfiguration.kt +95 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfigurationResources.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentOkHttpNetwork.kt +101 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentRegistry.kt +136 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeSurfaceState.kt +115 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +236 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionPackage.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeConfigurationModule.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeDocumentsModule.kt +82 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeSurfaceModule.kt +277 -0
- package/android/src/main/res/raw/tileflow_configuration_keep.xml +3 -0
- package/dist/contract-DWs-lDPo.d.ts +144 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +6989 -0
- package/dist/internal/camera.d.ts +65 -0
- package/dist/internal/camera.js +325 -0
- package/dist/internal/hosted-binding.d.ts +18 -0
- package/dist/internal/hosted-binding.js +277 -0
- package/dist/internal/initial-view.d.ts +11 -0
- package/dist/internal/initial-view.js +8 -0
- package/dist/internal/interactions.d.ts +76 -0
- package/dist/internal/interactions.js +794 -0
- package/dist/internal/native-admission-bridge.d.ts +29 -0
- package/dist/internal/native-admission-bridge.js +351 -0
- package/dist/internal/native-admission.d.ts +78 -0
- package/dist/internal/native-admission.js +1614 -0
- package/dist/internal/native-appearance.d.ts +21 -0
- package/dist/internal/native-appearance.js +110 -0
- package/dist/internal/native-configuration-bridge.d.ts +9 -0
- package/dist/internal/native-configuration-bridge.js +343 -0
- package/dist/internal/session.d.ts +125 -0
- package/dist/internal/session.js +887 -0
- package/dist/internal/source-state.d.ts +11 -0
- package/dist/internal/source-state.js +26 -0
- package/dist/native-admission-contract-k7LKE1vW.d.ts +89 -0
- package/docs/native-admission.md +108 -0
- package/docs/native-configuration.md +210 -0
- package/docs/native-interactions.md +161 -0
- package/docs/native-location.md +148 -0
- package/ios/TFAdmissionBootstrap.h +17 -0
- package/ios/TFAdmissionBootstrap.mm +168 -0
- package/ios/TFAdmissionCatalog.h +13 -0
- package/ios/TFAdmissionCatalog.mm +193 -0
- package/ios/TFAdmissionDocumentScope.h +7 -0
- package/ios/TFAdmissionDocumentScope.mm +45 -0
- package/ios/TFAdmissionEngine.h +57 -0
- package/ios/TFAdmissionEngine.mm +371 -0
- package/ios/TFAdmissionInstallation.h +27 -0
- package/ios/TFAdmissionInstallation.mm +117 -0
- package/ios/TFAdmissionNetwork.h +16 -0
- package/ios/TFAdmissionNetwork.mm +268 -0
- package/ios/TFAdmissionURL.mm +116 -0
- package/ios/TFDocumentBounds.h +9 -0
- package/ios/TFDocumentBounds.mm +19 -0
- package/ios/TFMobileConfiguration.h +23 -0
- package/ios/TFMobileConfiguration.mm +139 -0
- package/ios/TFNativeAdmissionURLProtocol.mm +69 -0
- package/ios/TFNativeDocumentRegistry.h +28 -0
- package/ios/TFNativeDocumentRegistry.mm +193 -0
- package/ios/TFNativeDocumentURL.h +6 -0
- package/ios/TFNativeSurfaceState.h +28 -0
- package/ios/TFNativeSurfaceState.mm +128 -0
- package/ios/TileflowNativeAdmission.h +9 -0
- package/ios/TileflowNativeAdmission.mm +184 -0
- package/ios/TileflowNativeConfiguration.mm +41 -0
- package/ios/TileflowNativeDocuments.h +12 -0
- package/ios/TileflowNativeDocuments.mm +105 -0
- package/ios/TileflowNativeSurface.mm +403 -0
- package/ios/tileflow_native_admission.rb +45 -0
- package/package.json +51 -7
- package/react-native.config.cjs +13 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream
|
|
4
|
+
import java.nio.ByteBuffer
|
|
5
|
+
import java.nio.charset.CodingErrorAction
|
|
6
|
+
|
|
7
|
+
/** A finite set of exact documents and narrowly typed URL expansions. */
|
|
8
|
+
internal class AdmissionCatalog(resources: List<AdmissionResource>) {
|
|
9
|
+
private class Rule(val resource: AdmissionResource, val pattern: Regex?, val names: List<String>)
|
|
10
|
+
private val rules: List<Rule>
|
|
11
|
+
|
|
12
|
+
init {
|
|
13
|
+
if (resources.size > AdmissionLimits.RESOURCES) invalid()
|
|
14
|
+
val seen = mutableSetOf<String>()
|
|
15
|
+
rules = resources.map {
|
|
16
|
+
val resource = validate(it)
|
|
17
|
+
if (!seen.add(resource.url)) invalid()
|
|
18
|
+
if (resource.template == null) Rule(resource, null, emptyList())
|
|
19
|
+
else {
|
|
20
|
+
val names = mutableListOf<String>()
|
|
21
|
+
var cursor = 0
|
|
22
|
+
val expression = StringBuilder("^")
|
|
23
|
+
for (match in placeholder.findAll(resource.url)) {
|
|
24
|
+
val name = match.groupValues[1]
|
|
25
|
+
if (names.size >= 16 || (names.isNotEmpty() && match.range.first == cursor)) invalid()
|
|
26
|
+
expression.append(Regex.escape(resource.url.substring(cursor, match.range.first)))
|
|
27
|
+
expression.append('(').append(slots[name] ?: invalid()).append(')')
|
|
28
|
+
names.add(name)
|
|
29
|
+
cursor = match.range.last + 1
|
|
30
|
+
}
|
|
31
|
+
if (names.isEmpty()) invalid()
|
|
32
|
+
expression.append(Regex.escape(resource.url.substring(cursor))).append('$')
|
|
33
|
+
Rule(resource, Regex(expression.toString()), names.toList())
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fun find(url: String): AdmissionResource? {
|
|
39
|
+
try { AdmissionUrl.clean(url) } catch (_: Exception) { return null }
|
|
40
|
+
var found: AdmissionResource? = null
|
|
41
|
+
for (rule in rules) {
|
|
42
|
+
if (!matches(rule, url)) continue
|
|
43
|
+
if (found != null) invalid()
|
|
44
|
+
found = AdmissionResource(url, rule.resource.scope, rule.resource.tilesetId)
|
|
45
|
+
}
|
|
46
|
+
return found
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun matches(rule: Rule, url: String): Boolean {
|
|
50
|
+
val pattern = rule.pattern ?: return rule.resource.url == url
|
|
51
|
+
val match = pattern.matchEntire(url) ?: return false
|
|
52
|
+
val values = mutableMapOf<String, String>()
|
|
53
|
+
for ((index, name) in rule.names.withIndex()) {
|
|
54
|
+
val value = match.groupValues[index + 1]
|
|
55
|
+
if (values.containsKey(name) && values[name] != value) return false
|
|
56
|
+
values[name] = value
|
|
57
|
+
}
|
|
58
|
+
val zoom = values["z"]?.toIntOrNull()
|
|
59
|
+
if (zoom != null && zoom > 30) return false
|
|
60
|
+
for (name in listOf("x", "y")) {
|
|
61
|
+
val coordinate = values[name]?.toLongOrNull() ?: continue
|
|
62
|
+
if (coordinate >= (1L shl (zoom ?: 30))) return false
|
|
63
|
+
}
|
|
64
|
+
values["quadkey"]?.let { if (zoom != null && it.length != zoom) return false }
|
|
65
|
+
values["bbox-epsg-3857"]?.let { box ->
|
|
66
|
+
val numbers = box.split(',').map { it.toDoubleOrNull() ?: return false }
|
|
67
|
+
if (numbers.size != 4 || numbers.any { !it.isFinite() || kotlin.math.abs(it) > 20037508.343 } || numbers[0] >= numbers[2] || numbers[1] >= numbers[3]) return false
|
|
68
|
+
}
|
|
69
|
+
values["range"]?.let { range ->
|
|
70
|
+
val pair = range.split('-').map { it.toLongOrNull() ?: return false }
|
|
71
|
+
if (pair.size != 2 || pair[0] % 256 != 0L || pair[1] != pair[0] + 255 || pair[1] > 1114111) return false
|
|
72
|
+
}
|
|
73
|
+
values["fontstack"]?.let { stack ->
|
|
74
|
+
val decoded = try { decode(stack) } catch (_: Exception) { return false }
|
|
75
|
+
if (rule.resource.fontStacks?.contains(decoded) != true) return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
companion object {
|
|
81
|
+
private val placeholder = Regex("\\{([a-z0-9-]+)\\}")
|
|
82
|
+
private val decimal = "(?:0|[1-9][0-9]{0,9})"
|
|
83
|
+
private val coordinate = "-?(?:0|[1-9][0-9]{0,7})(?:\\.[0-9]{1,16})?"
|
|
84
|
+
private val slots = mapOf(
|
|
85
|
+
"z" to "(?:0|[1-9][0-9]?)", "x" to decimal, "y" to decimal,
|
|
86
|
+
"ratio" to "(?:@2x|@3x)?", "quadkey" to "[0-3]{0,30}", "prefix" to "[0-9a-f]{2}",
|
|
87
|
+
"bbox-epsg-3857" to "$coordinate,$coordinate,$coordinate,$coordinate",
|
|
88
|
+
"fontstack" to "(?:[A-Za-z0-9_.~,+-]|%[0-9a-fA-F]{2}){1,768}", "range" to "$decimal-$decimal"
|
|
89
|
+
)
|
|
90
|
+
private val scopes = setOf("style", "tilejson", "tile", "sprite", "glyph", "font")
|
|
91
|
+
private val compact = Regex("[A-Za-z0-9._:-]{1,255}")
|
|
92
|
+
|
|
93
|
+
fun validate(value: AdmissionResource): AdmissionResource {
|
|
94
|
+
try {
|
|
95
|
+
if (value.url.length > AdmissionLimits.URL - 128 || !scopes.contains(value.scope)) invalid()
|
|
96
|
+
if ((value.scope == "tile" || value.scope == "tilejson") && value.tilesetId == null) invalid()
|
|
97
|
+
value.tilesetId?.let { if (!compact.matches(it) || it.contains("tf_native_", true) || it.contains("tf_public_", true)) invalid() }
|
|
98
|
+
if (value.template == null) {
|
|
99
|
+
if (value.fontStacks != null || value.url.contains('{') || value.url.contains('}')) invalid()
|
|
100
|
+
AdmissionUrl.clean(value.url)
|
|
101
|
+
return AdmissionResource(value.url, value.scope, value.tilesetId)
|
|
102
|
+
}
|
|
103
|
+
if (value.template !in setOf("tile", "glyphs")) invalid()
|
|
104
|
+
val authority = value.url.substringBefore('/', "").takeIf { it.isNotEmpty() }
|
|
105
|
+
if (authority == null || !value.url.startsWith("https://")) invalid()
|
|
106
|
+
val path = value.url.indexOf('/', 8)
|
|
107
|
+
if (path < 0 || value.url.substring(0, path).any { it == '{' || it == '}' }) invalid()
|
|
108
|
+
val names = placeholder.findAll(value.url).map { it.groupValues[1] }.toList()
|
|
109
|
+
if (names.isEmpty() || names.size > 16 || names.any { !slots.containsKey(it) }) invalid()
|
|
110
|
+
val masked = placeholder.replace(value.url, "native-template-slot")
|
|
111
|
+
if (masked.contains('{') || masked.contains('}')) invalid()
|
|
112
|
+
AdmissionUrl.clean(masked)
|
|
113
|
+
if (value.template == "tile") {
|
|
114
|
+
if (value.scope != "tile" || value.fontStacks != null || names.any { it == "fontstack" || it == "range" }) invalid()
|
|
115
|
+
return AdmissionResource(value.url, value.scope, value.tilesetId, "tile")
|
|
116
|
+
}
|
|
117
|
+
val stacks = value.fontStacks ?: invalid()
|
|
118
|
+
if (value.scope != "glyph" || !names.containsAll(listOf("fontstack", "range")) || names.any { it != "fontstack" && it != "range" } || stacks.isEmpty() || stacks.size > 16 || stacks.toSet().size != stacks.size) invalid()
|
|
119
|
+
for (stack in stacks) {
|
|
120
|
+
if (stack.isEmpty() || stack.length > 256 || stack != stack.trim() || stack.any { it.code < 32 || it.code in 127..159 || it in "\\/?#%&=" } || stack.contains("tf_native_", true) || stack.contains("tf_public_", true) || stack.split(',').any { it.isEmpty() || it != it.trim() }) invalid()
|
|
121
|
+
}
|
|
122
|
+
return AdmissionResource(value.url, value.scope, value.tilesetId, "glyphs", stacks.toList())
|
|
123
|
+
} catch (_: Exception) { invalid() }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private fun decode(value: String): String {
|
|
127
|
+
val bytes = ByteArrayOutputStream()
|
|
128
|
+
var index = 0
|
|
129
|
+
while (index < value.length) {
|
|
130
|
+
if (value[index] == '%') { bytes.write(value.substring(index + 1, index + 3).toInt(16)); index += 3 }
|
|
131
|
+
else { bytes.write(value[index].code); index++ }
|
|
132
|
+
}
|
|
133
|
+
return Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes.toByteArray())).toString()
|
|
134
|
+
}
|
|
135
|
+
private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native resource catalog")
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
4
|
+
|
|
5
|
+
internal fun createAdmissionDocumentScope(admission: AdmissionEngine, context: String, maximumBytes: Int): NativeDocumentScope {
|
|
6
|
+
if (maximumBytes !in 1..8388608) throw IllegalArgumentException("Native document acquisition failed.")
|
|
7
|
+
val active = admission.contextGuard(context)
|
|
8
|
+
return NativeDocumentScope(active, { url, completion ->
|
|
9
|
+
AdmissionUrl.clean(url)
|
|
10
|
+
val completed = AtomicBoolean(false)
|
|
11
|
+
val finish: (AdmissionHttpResponse?) -> Unit = { response ->
|
|
12
|
+
if (completed.compareAndSet(false, true)) completion(response)
|
|
13
|
+
}
|
|
14
|
+
val tagged = "$url${if (url.contains('?')) '&' else '?'}__tf_native_context=$context"
|
|
15
|
+
val request = admission.requestDocument(tagged, maximumBytes, { finish(it) }, { finish(null) })
|
|
16
|
+
AdmissionCancellation {
|
|
17
|
+
request.cancel()
|
|
18
|
+
// The admission transport independently retains its physical network slot.
|
|
19
|
+
finish(null)
|
|
20
|
+
}
|
|
21
|
+
}, context)
|
|
22
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
4
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
5
|
+
|
|
6
|
+
// All mutable collections are confined to the injected serial scheduler.
|
|
7
|
+
// Atomic liveness and volatile deadlines are also read at OkHttp network start.
|
|
8
|
+
internal class AdmissionEngine(
|
|
9
|
+
private val installation: String,
|
|
10
|
+
private val scheduler: AdmissionScheduler,
|
|
11
|
+
private val network: AdmissionNetwork,
|
|
12
|
+
private val owns: () -> Boolean,
|
|
13
|
+
private val emit: (Map<String, Any>) -> Unit,
|
|
14
|
+
) {
|
|
15
|
+
private class Context(val id: String, val mapId: String?, var resources: Map<String, AdmissionResource>) {
|
|
16
|
+
var catalog = AdmissionCatalog(resources.values.toList())
|
|
17
|
+
val live = AtomicBoolean(true)
|
|
18
|
+
val work = linkedMapOf<String, Work>()
|
|
19
|
+
var batch: Batch? = null
|
|
20
|
+
var sequence = 0L
|
|
21
|
+
}
|
|
22
|
+
private class Batch(val id: String, val tickets: List<String>) {
|
|
23
|
+
var timer: AdmissionCancellation? = null
|
|
24
|
+
}
|
|
25
|
+
private class Work(
|
|
26
|
+
val id: String,
|
|
27
|
+
val context: Context,
|
|
28
|
+
val url: String,
|
|
29
|
+
val headers: Map<String, String>,
|
|
30
|
+
val maximumBytes: Int,
|
|
31
|
+
val entered: Long,
|
|
32
|
+
val live: AtomicBoolean,
|
|
33
|
+
val response: (AdmissionHttpResponse) -> Unit,
|
|
34
|
+
val failure: () -> Unit,
|
|
35
|
+
val delegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
|
|
36
|
+
val release: () -> Unit,
|
|
37
|
+
) {
|
|
38
|
+
var phase = "queue"
|
|
39
|
+
var cancellation: AdmissionCancellation? = null
|
|
40
|
+
var timer: AdmissionCancellation? = null
|
|
41
|
+
var authority: AdmissionAuthority? = null
|
|
42
|
+
@Volatile var deadline = 0L
|
|
43
|
+
var redirects = 0
|
|
44
|
+
}
|
|
45
|
+
private val contexts = linkedMapOf<String, Context>()
|
|
46
|
+
private val live = AtomicBoolean(true)
|
|
47
|
+
private val lossQueued = AtomicBoolean(false)
|
|
48
|
+
private val reservations = AtomicInteger(0)
|
|
49
|
+
@Volatile private var foreground = true
|
|
50
|
+
private var contextSequence = 0L
|
|
51
|
+
private var ticketSequence = 0L
|
|
52
|
+
|
|
53
|
+
fun register(mapId: String?, resources: List<AdmissionResource>): String {
|
|
54
|
+
if (!checkOwnership() || contexts.size >= AdmissionLimits.CONTEXTS || resources.size > AdmissionLimits.RESOURCES ||
|
|
55
|
+
(mapId != null && !Regex("map_[A-Za-z0-9_-]{16}").matches(mapId))) invalid()
|
|
56
|
+
val catalog = linkedMapOf<String, AdmissionResource>()
|
|
57
|
+
for (resource in resources) {
|
|
58
|
+
val snapshot = AdmissionCatalog.validate(resource)
|
|
59
|
+
if (catalog.put(snapshot.url, snapshot) != null) invalid()
|
|
60
|
+
}
|
|
61
|
+
if (contextSequence >= 9007199254740991L) invalid()
|
|
62
|
+
val id = "$installation.${++contextSequence}"
|
|
63
|
+
contexts[id] = Context(id, mapId, catalog)
|
|
64
|
+
return id
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Capture the original atomic context lifetime rather than consulting a mutable registry off-thread.
|
|
68
|
+
fun contextGuard(contextId: String): () -> Boolean {
|
|
69
|
+
val context = contexts[contextId] ?: invalid()
|
|
70
|
+
if (!context.live.get() || !checkOwnership()) invalid()
|
|
71
|
+
return { context.live.get() && foreground && checkOwnership() }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private fun extension(previous: AdmissionResource, next: AdmissionResource): AdmissionResource {
|
|
75
|
+
if (previous.scope != next.scope || previous.tilesetId != next.tilesetId || previous.template != next.template) invalid()
|
|
76
|
+
if (previous.fontStacks == next.fontStacks) return previous
|
|
77
|
+
if (previous.scope != "glyph" || previous.template != "glyphs") invalid()
|
|
78
|
+
val before = previous.fontStacks ?: invalid()
|
|
79
|
+
val after = next.fontStacks ?: invalid()
|
|
80
|
+
if (!after.containsAll(before) || after.size > 16) invalid()
|
|
81
|
+
return AdmissionCatalog.validate(
|
|
82
|
+
AdmissionResource(previous.url, previous.scope, previous.tilesetId, previous.template, after)
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Append-only within a context: rollback can still use its last accepted style.
|
|
87
|
+
// Identical retries are acknowledged; only a glyph template may widen its finite stack set.
|
|
88
|
+
fun extend(contextId: String, resources: List<AdmissionResource>): Int {
|
|
89
|
+
val context = contexts[contextId] ?: invalid()
|
|
90
|
+
if (!checkOwnership() || !context.live.get() || resources.size > AdmissionLimits.RESOURCES) invalid()
|
|
91
|
+
val next = LinkedHashMap(context.resources)
|
|
92
|
+
for (resource in resources) {
|
|
93
|
+
val snapshot = AdmissionCatalog.validate(resource)
|
|
94
|
+
val previous = next[snapshot.url]
|
|
95
|
+
next[snapshot.url] = if (previous == null) snapshot else extension(previous, snapshot)
|
|
96
|
+
}
|
|
97
|
+
val catalog = AdmissionCatalog(next.values.toList())
|
|
98
|
+
context.resources = next
|
|
99
|
+
context.catalog = catalog
|
|
100
|
+
return next.size
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun resource(context: Context, url: String): AdmissionResource? =
|
|
104
|
+
try { context.catalog.find(url) } catch (_: Exception) { null }
|
|
105
|
+
|
|
106
|
+
fun request(
|
|
107
|
+
url: String,
|
|
108
|
+
headers: Map<String, String>,
|
|
109
|
+
onResponse: (AdmissionHttpResponse) -> Unit,
|
|
110
|
+
onFailure: () -> Unit,
|
|
111
|
+
onDelegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
|
|
112
|
+
): AdmissionCancellation = requestBounded(url, headers, AdmissionLimits.RESPONSE_BYTES.toInt(), onResponse, onFailure, onDelegate)
|
|
113
|
+
|
|
114
|
+
fun requestDocument(
|
|
115
|
+
url: String,
|
|
116
|
+
maximumBytes: Int,
|
|
117
|
+
onResponse: (AdmissionHttpResponse) -> Unit,
|
|
118
|
+
onFailure: () -> Unit,
|
|
119
|
+
): AdmissionCancellation = requestBounded(url, emptyMap(), maximumBytes, onResponse, onFailure, { _, completion ->
|
|
120
|
+
// Unprotected preparation uses the separate credential-free document channel.
|
|
121
|
+
completion(null); AdmissionCancellation {}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
private fun requestBounded(
|
|
125
|
+
url: String,
|
|
126
|
+
headers: Map<String, String>,
|
|
127
|
+
maximumBytes: Int,
|
|
128
|
+
onResponse: (AdmissionHttpResponse) -> Unit,
|
|
129
|
+
onFailure: () -> Unit,
|
|
130
|
+
onDelegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
|
|
131
|
+
): AdmissionCancellation {
|
|
132
|
+
if (maximumBytes !in 1..AdmissionLimits.RESPONSE_BYTES.toInt()) invalid()
|
|
133
|
+
// Bound ingress before posting to the main scheduler. Cancellation
|
|
134
|
+
// retains this transport slot until serialized cleanup has run.
|
|
135
|
+
if (reservations.incrementAndGet() > AdmissionLimits.CONTEXTS * AdmissionLimits.QUEUE) {
|
|
136
|
+
reservations.decrementAndGet(); onFailure(); return AdmissionCancellation {}
|
|
137
|
+
}
|
|
138
|
+
val reserved = AtomicBoolean(true)
|
|
139
|
+
val release: () -> Unit = { if (reserved.getAndSet(false)) reservations.decrementAndGet() }
|
|
140
|
+
val active = AtomicBoolean(true)
|
|
141
|
+
var work: Work? = null
|
|
142
|
+
scheduler.dispatch {
|
|
143
|
+
if (!active.get()) { release(); return@dispatch }
|
|
144
|
+
val tagged = try { AdmissionUrl.strip(url) } catch (_: Exception) {
|
|
145
|
+
active.set(false); release(); onFailure(); return@dispatch
|
|
146
|
+
}
|
|
147
|
+
val context = contexts[tagged.context]
|
|
148
|
+
if (!foreground || !checkOwnership() || context == null || !context.live.get() ||
|
|
149
|
+
resource(context, tagged.url) == null || context.work.size >= AdmissionLimits.QUEUE ||
|
|
150
|
+
headers.keys.any { it.equals(AdmissionLimits.GRANT_HEADER, true) } ||
|
|
151
|
+
ticketSequence >= 9007199254740991L) {
|
|
152
|
+
active.set(false); release(); onFailure(); return@dispatch
|
|
153
|
+
}
|
|
154
|
+
val item = Work((++ticketSequence).toString(), context, tagged.url, headers.toMap(), maximumBytes, scheduler.nowMs(), active, onResponse, onFailure, onDelegate, release)
|
|
155
|
+
work = item
|
|
156
|
+
context.work[item.id] = item
|
|
157
|
+
item.timer = scheduler.after(AdmissionLimits.WAIT_MS) {
|
|
158
|
+
if (context.batch?.tickets?.contains(item.id) == true) retire(context.id)
|
|
159
|
+
else fail(item)
|
|
160
|
+
}
|
|
161
|
+
scheduler.dispatch { drain(context) }
|
|
162
|
+
}
|
|
163
|
+
return AdmissionCancellation {
|
|
164
|
+
if (active.compareAndSet(true, false)) scheduler.dispatch {
|
|
165
|
+
work?.let { item ->
|
|
166
|
+
if (item.context.batch?.tickets?.contains(item.id) == true) {
|
|
167
|
+
event(item.context, "cancel", mapOf("tickets" to listOf(item.id)))
|
|
168
|
+
}
|
|
169
|
+
finish(item)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun drain(context: Context) {
|
|
176
|
+
if (!context.live.get() || !foreground || !checkOwnership() || context.batch != null) return
|
|
177
|
+
val selected = context.work.values.filter { it.live.get() && it.phase == "queue" }.take(AdmissionLimits.BATCH)
|
|
178
|
+
if (selected.isEmpty()) return
|
|
179
|
+
if (context.sequence >= 9007199254740991L) { retire(context.id); return }
|
|
180
|
+
val batch = Batch((++context.sequence).toString(), selected.map { it.id })
|
|
181
|
+
context.batch = batch
|
|
182
|
+
for (item in selected) item.phase = "javascript"
|
|
183
|
+
batch.timer = scheduler.after(AdmissionLimits.WAIT_MS) {
|
|
184
|
+
if (context.batch === batch) retire(context.id)
|
|
185
|
+
}
|
|
186
|
+
event(context, "batch", mapOf("batch" to batch.id, "tickets" to selected.map { mapOf("ticket" to it.id, "url" to it.url) }))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fun complete(contextId: String, generation: Long, batchId: String, results: List<AdmissionResult>): Int {
|
|
190
|
+
val context = contexts[contextId] ?: return 0
|
|
191
|
+
val batch = context.batch ?: return 0
|
|
192
|
+
if (!context.live.get() || generation != 1L || batch.id != batchId || !checkOwnership()) return 0
|
|
193
|
+
if (results.size > AdmissionLimits.BATCH || results.map { it.ticket } != batch.tickets) { retire(contextId); return 0 }
|
|
194
|
+
batch.timer?.cancel()
|
|
195
|
+
context.batch = null
|
|
196
|
+
var accepted = 0
|
|
197
|
+
for (result in results) {
|
|
198
|
+
val item = context.work[result.ticket] ?: continue
|
|
199
|
+
if (!item.live.get()) { finish(item); continue }
|
|
200
|
+
if (!foreground || scheduler.nowMs() - item.entered >= AdmissionLimits.WAIT_MS) { fail(item); continue }
|
|
201
|
+
when (result) {
|
|
202
|
+
is AdmissionResult.Reject -> fail(item)
|
|
203
|
+
is AdmissionResult.Delegate -> {
|
|
204
|
+
if (context.mapId != null) { fail(item); continue }
|
|
205
|
+
item.phase = "network"
|
|
206
|
+
item.deadline = item.entered + AdmissionLimits.WAIT_MS
|
|
207
|
+
if (!canStart(item)) { fail(item); continue }
|
|
208
|
+
try {
|
|
209
|
+
item.cancellation = item.delegate(item.url) { response -> scheduler.dispatch { received(item, item.url, response) } }
|
|
210
|
+
if (!item.live.get()) item.cancellation?.cancel()
|
|
211
|
+
accepted++
|
|
212
|
+
} catch (_: Exception) { fail(item) }
|
|
213
|
+
}
|
|
214
|
+
is AdmissionResult.Grant -> {
|
|
215
|
+
val selected = resource(context, item.url)
|
|
216
|
+
val authority = result.authority
|
|
217
|
+
if (context.mapId == null || selected == null || result.validForMs !in 1..AdmissionLimits.MAX_VALID_MS ||
|
|
218
|
+
authority.grant.length > AdmissionLimits.GRANT || !Regex("tf_native_v1\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+").matches(authority.grant) ||
|
|
219
|
+
!authority.allows(selected, context.mapId)) { fail(item); continue }
|
|
220
|
+
item.authority = authority
|
|
221
|
+
// Anchor at the earlier enqueue time, never at bridge receipt.
|
|
222
|
+
item.deadline = minOf(item.entered + AdmissionLimits.WAIT_MS, item.entered + result.validForMs - AdmissionLimits.SAFETY_MS)
|
|
223
|
+
if (start(item, item.url)) accepted++
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
scheduler.dispatch { drain(context) }
|
|
228
|
+
return accepted
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private fun canStart(item: Work): Boolean {
|
|
232
|
+
if (!item.live.get() || !item.context.live.get() || !foreground || !live.get() || scheduler.nowMs() >= item.deadline) return false
|
|
233
|
+
if (!ownsSafely()) { loseOwnership(); return false }
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private fun start(item: Work, url: String): Boolean {
|
|
238
|
+
val authority = item.authority ?: return false
|
|
239
|
+
val selected = resource(item.context, url)
|
|
240
|
+
val mapId = item.context.mapId
|
|
241
|
+
if (selected == null || mapId == null || !authority.allows(selected, mapId) || !canStart(item)) { fail(item); return false }
|
|
242
|
+
item.phase = "network"
|
|
243
|
+
val headers = item.headers + (AdmissionLimits.GRANT_HEADER to authority.grant)
|
|
244
|
+
return try {
|
|
245
|
+
item.cancellation = network.start(AdmissionHttpRequest(url, headers, item.maximumBytes) { canStart(item) }) { response ->
|
|
246
|
+
scheduler.dispatch { received(item, url, response) }
|
|
247
|
+
}
|
|
248
|
+
if (!item.live.get()) item.cancellation?.cancel()
|
|
249
|
+
true
|
|
250
|
+
} catch (_: Exception) { fail(item); false }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private fun received(item: Work, url: String, response: AdmissionHttpResponse?) {
|
|
254
|
+
if (!item.live.get() || !item.context.live.get()) return
|
|
255
|
+
if (!checkOwnership()) return
|
|
256
|
+
if (response == null || response.body.size > item.maximumBytes || !foreground || scheduler.nowMs() >= item.deadline) { fail(item); return }
|
|
257
|
+
event(item.context, "response", mapOf("status" to response.code))
|
|
258
|
+
if (item.authority != null && response.code in listOf(301, 302, 303, 307, 308)) {
|
|
259
|
+
if (++item.redirects > AdmissionLimits.REDIRECTS) { fail(item); return }
|
|
260
|
+
val location = response.headers.entries.firstOrNull { it.key.equals("location", true) }?.value
|
|
261
|
+
val target = try { AdmissionUrl.redirect(url, location ?: "", item.context.id) } catch (_: Exception) { fail(item); return }
|
|
262
|
+
if (location.isNullOrEmpty()) { fail(item); return }
|
|
263
|
+
start(item, target)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
if (!item.live.compareAndSet(true, false)) return
|
|
267
|
+
finish(item)
|
|
268
|
+
item.response(AdmissionHttpResponse(response.code, response.headers, response.body, url))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private fun finish(item: Work) {
|
|
272
|
+
item.live.set(false)
|
|
273
|
+
item.cancellation?.cancel()
|
|
274
|
+
item.cancellation = null
|
|
275
|
+
item.timer?.cancel()
|
|
276
|
+
item.timer = null
|
|
277
|
+
item.authority = null
|
|
278
|
+
item.context.work.remove(item.id)
|
|
279
|
+
item.release()
|
|
280
|
+
scheduler.dispatch { drain(item.context) }
|
|
281
|
+
}
|
|
282
|
+
private fun fail(item: Work) {
|
|
283
|
+
val notify = item.live.getAndSet(false)
|
|
284
|
+
finish(item)
|
|
285
|
+
if (notify) item.failure()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fun retire(contextId: String) {
|
|
289
|
+
val context = contexts.remove(contextId) ?: return
|
|
290
|
+
context.live.set(false)
|
|
291
|
+
context.batch?.timer?.cancel()
|
|
292
|
+
context.batch = null
|
|
293
|
+
for (item in context.work.values.toList()) fail(item)
|
|
294
|
+
event(context, "retired", mapOf("code" to "NATIVE_ADMISSION_CANCELLED"))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fun lifecycle(active: Boolean) {
|
|
298
|
+
foreground = active
|
|
299
|
+
if (!active) {
|
|
300
|
+
for (context in contexts.values.toList()) {
|
|
301
|
+
val tickets = context.batch?.tickets
|
|
302
|
+
if (!tickets.isNullOrEmpty()) event(context, "cancel", mapOf("tickets" to tickets))
|
|
303
|
+
for (item in context.work.values.toList()) fail(item)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
safeEmit(mapOf("kind" to "lifecycle", "installation" to installation, "foreground" to active))
|
|
307
|
+
if (active) for (context in contexts.values) scheduler.dispatch { drain(context) }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fun close() {
|
|
311
|
+
live.set(false)
|
|
312
|
+
for (context in contexts.keys.toList()) retire(context)
|
|
313
|
+
}
|
|
314
|
+
fun checkOwnership(): Boolean {
|
|
315
|
+
if (!live.get()) return false
|
|
316
|
+
if (!ownsSafely()) { loseOwnership(); return false }
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
private fun ownsSafely() = try { owns() } catch (_: Exception) { false }
|
|
320
|
+
private fun loseOwnership() {
|
|
321
|
+
live.set(false)
|
|
322
|
+
if (!lossQueued.compareAndSet(false, true)) return
|
|
323
|
+
scheduler.dispatch {
|
|
324
|
+
close()
|
|
325
|
+
safeEmit(mapOf("kind" to "ownershipLost", "installation" to installation))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
private fun event(context: Context, kind: String, fields: Map<String, Any>) {
|
|
329
|
+
safeEmit(mapOf("kind" to kind, "installation" to installation, "context" to context.id, "generation" to 1) + fields)
|
|
330
|
+
}
|
|
331
|
+
private fun safeEmit(event: Map<String, Any>) {
|
|
332
|
+
try { emit(event) } catch (_: Exception) { close() }
|
|
333
|
+
}
|
|
334
|
+
private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native admission")
|
|
335
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
4
|
+
import org.maplibre.android.LibraryLoaderProvider
|
|
5
|
+
import org.maplibre.android.ModuleProvider
|
|
6
|
+
import org.maplibre.android.http.HttpRequest
|
|
7
|
+
import org.maplibre.android.http.HttpResponder
|
|
8
|
+
|
|
9
|
+
internal class AdmissionModuleProvider(
|
|
10
|
+
private val previous: ModuleProvider,
|
|
11
|
+
private val engine: AdmissionEngine,
|
|
12
|
+
) : ModuleProvider {
|
|
13
|
+
override fun createLibraryLoaderProvider(): LibraryLoaderProvider = previous.createLibraryLoaderProvider()
|
|
14
|
+
override fun createHttpRequest(): HttpRequest = Request(previous.createHttpRequest(), engine)
|
|
15
|
+
|
|
16
|
+
private class Request(private val previous: HttpRequest, private val engine: AdmissionEngine) : HttpRequest {
|
|
17
|
+
private val cancelled = AtomicBoolean(false)
|
|
18
|
+
private val used = AtomicBoolean(false)
|
|
19
|
+
@Volatile private var delegated = false
|
|
20
|
+
@Volatile private var cancellation: AdmissionCancellation? = null
|
|
21
|
+
|
|
22
|
+
override fun executeRequest(responder: HttpResponder, nativePtr: Long, resourceUrl: String, dataRange: String, etag: String, modified: String, offlineUsage: Boolean) {
|
|
23
|
+
if (cancelled.get()) return
|
|
24
|
+
if (!used.compareAndSet(false, true)) {
|
|
25
|
+
responder.handleFailure(HttpRequest.PERMANENT_ERROR, "Native request was already started")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (!AdmissionUrl.reserved(resourceUrl)) {
|
|
29
|
+
// Unowned traffic does not pass through our queue or header code.
|
|
30
|
+
delegated = true
|
|
31
|
+
previous.executeRequest(responder, nativePtr, resourceUrl, dataRange, etag, modified, offlineUsage)
|
|
32
|
+
if (cancelled.get()) previous.cancelRequest()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
val headers = linkedMapOf<String, String>()
|
|
36
|
+
if (dataRange.isNotEmpty()) headers["Range"] = dataRange
|
|
37
|
+
if (etag.isNotEmpty()) headers["If-None-Match"] = etag
|
|
38
|
+
else if (modified.isNotEmpty()) headers["If-Modified-Since"] = modified
|
|
39
|
+
cancellation = engine.request(resourceUrl, headers,
|
|
40
|
+
{ response ->
|
|
41
|
+
if (!cancelled.get()) responder.onResponse(response.code,
|
|
42
|
+
response.headers["etag"], response.headers["last-modified"], response.headers["cache-control"],
|
|
43
|
+
response.headers["expires"], response.headers["retry-after"], response.headers["x-rate-limit-reset"], response.body)
|
|
44
|
+
},
|
|
45
|
+
{ if (!cancelled.get()) responder.handleFailure(HttpRequest.TEMPORARY_ERROR, "Native resource admission failed") },
|
|
46
|
+
{ clean, done ->
|
|
47
|
+
previous.executeRequest(object : HttpResponder {
|
|
48
|
+
override fun onResponse(responseCode: Int, eTag: String?, lastModified: String?, cacheControl: String?, expires: String?, retryAfter: String?, xRateLimitReset: String?, body: ByteArray?) {
|
|
49
|
+
val metadata = linkedMapOf<String, String>()
|
|
50
|
+
for ((name, value) in listOf("etag" to eTag, "last-modified" to lastModified, "cache-control" to cacheControl, "expires" to expires, "retry-after" to retryAfter, "x-rate-limit-reset" to xRateLimitReset)) {
|
|
51
|
+
if (value != null) metadata[name] = value
|
|
52
|
+
}
|
|
53
|
+
done(AdmissionHttpResponse(responseCode, metadata, body ?: byteArrayOf()))
|
|
54
|
+
}
|
|
55
|
+
override fun handleFailure(type: Int, errorMessage: String?) { done(null) }
|
|
56
|
+
}, nativePtr, clean, dataRange, etag, modified, offlineUsage)
|
|
57
|
+
AdmissionCancellation { previous.cancelRequest() }
|
|
58
|
+
})
|
|
59
|
+
if (cancelled.get()) cancellation?.cancel()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun cancelRequest() {
|
|
63
|
+
cancelled.set(true)
|
|
64
|
+
cancellation?.cancel()
|
|
65
|
+
if (delegated) previous.cancelRequest()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.io.IOException
|
|
4
|
+
import java.util.concurrent.TimeUnit
|
|
5
|
+
import okhttp3.Call
|
|
6
|
+
import okhttp3.Callback
|
|
7
|
+
import okhttp3.Dispatcher
|
|
8
|
+
import okhttp3.OkHttpClient
|
|
9
|
+
import okhttp3.Request
|
|
10
|
+
import okhttp3.Response
|
|
11
|
+
|
|
12
|
+
internal class AdmissionOkHttpNetwork(private val dispatcher: Dispatcher = Dispatcher()) : AdmissionNetwork {
|
|
13
|
+
private class StartGuard(val allowed: () -> Boolean) {
|
|
14
|
+
override fun toString() = "NativeAdmissionStartGuard"
|
|
15
|
+
}
|
|
16
|
+
private val lock = Any()
|
|
17
|
+
private val calls = mutableSetOf<Call>()
|
|
18
|
+
private var closed = false
|
|
19
|
+
init { dispatcher.maxRequests = 16; dispatcher.maxRequestsPerHost = 8 }
|
|
20
|
+
// This client is independent of MapLibre's global HTTP client and of the
|
|
21
|
+
// bootstrap channel. Never add a logging interceptor or shared cookie jar.
|
|
22
|
+
private val client = OkHttpClient.Builder()
|
|
23
|
+
.dispatcher(dispatcher)
|
|
24
|
+
.followRedirects(false)
|
|
25
|
+
.followSslRedirects(false)
|
|
26
|
+
.retryOnConnectionFailure(false)
|
|
27
|
+
.cache(null)
|
|
28
|
+
.connectTimeout(10, TimeUnit.SECONDS)
|
|
29
|
+
.callTimeout(30, TimeUnit.SECONDS)
|
|
30
|
+
.addNetworkInterceptor { chain ->
|
|
31
|
+
val guard = chain.request().tag(StartGuard::class.java)
|
|
32
|
+
if (guard == null || !guard.allowed()) throw IOException("Native admission is no longer valid")
|
|
33
|
+
// This is a nonblocking liveness/expiry check, never a JavaScript wait.
|
|
34
|
+
chain.proceed(chain.request())
|
|
35
|
+
}
|
|
36
|
+
.build()
|
|
37
|
+
|
|
38
|
+
override fun start(request: AdmissionHttpRequest, callback: (AdmissionHttpResponse?) -> Unit): AdmissionCancellation {
|
|
39
|
+
if (!request.mayStart()) { callback(null); return AdmissionCancellation {} }
|
|
40
|
+
val builder = Request.Builder().url(request.url).get().tag(StartGuard::class.java, StartGuard(request.mayStart))
|
|
41
|
+
for ((name, value) in request.headers) builder.header(name, value)
|
|
42
|
+
val call = client.newCall(builder.build())
|
|
43
|
+
val accepted = synchronized(lock) {
|
|
44
|
+
if (closed || calls.size >= AdmissionLimits.CONTEXTS * AdmissionLimits.QUEUE) false
|
|
45
|
+
else { calls.add(call); true }
|
|
46
|
+
}
|
|
47
|
+
if (!accepted) { callback(null); return AdmissionCancellation {} }
|
|
48
|
+
fun complete(response: AdmissionHttpResponse?) {
|
|
49
|
+
synchronized(lock) { calls.remove(call) }
|
|
50
|
+
callback(response)
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
call.enqueue(object : Callback {
|
|
54
|
+
override fun onFailure(call: Call, e: IOException) { complete(null) }
|
|
55
|
+
override fun onResponse(call: Call, response: Response) {
|
|
56
|
+
val result = try {
|
|
57
|
+
response.use {
|
|
58
|
+
val body = it.body
|
|
59
|
+
val bytes = body?.byteStream()?.use { stream ->
|
|
60
|
+
readNativeDocumentBody(stream, request.maximumBytes, body.contentLength(), request.mayStart)
|
|
61
|
+
} ?: byteArrayOf()
|
|
62
|
+
// Forward only metadata consumed by MapLibre and Location
|
|
63
|
+
// for our own redirect validation, never raw headers.
|
|
64
|
+
val headers = linkedMapOf<String, String>()
|
|
65
|
+
for (name in listOf("etag", "last-modified", "cache-control", "expires", "retry-after", "x-rate-limit-reset", "location")) {
|
|
66
|
+
it.header(name)?.let { value -> headers[name] = value }
|
|
67
|
+
}
|
|
68
|
+
AdmissionHttpResponse(it.code, headers, bytes)
|
|
69
|
+
}
|
|
70
|
+
} catch (_: Exception) { null }
|
|
71
|
+
complete(result)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
} catch (_: Exception) { call.cancel(); complete(null) }
|
|
75
|
+
// Logical cancellation does not release the physical dispatcher slot.
|
|
76
|
+
// Its terminal callback does, so rapid retire/register cannot grow it.
|
|
77
|
+
return AdmissionCancellation { call.cancel() }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun close() {
|
|
81
|
+
val pending = synchronized(lock) { closed = true; calls.toList() }
|
|
82
|
+
for (call in pending) call.cancel()
|
|
83
|
+
client.connectionPool.evictAll()
|
|
84
|
+
dispatcher.executorService.shutdown()
|
|
85
|
+
}
|
|
86
|
+
}
|