@tileflow/react-native 0.1.0-alpha.6 → 0.1.0-alpha.8
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 +72 -14
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +7 -3
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +3 -1
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +4 -2
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +1 -1
- package/dist/{contract-By3URNxS.d.ts → contract-BWT1jxqg.d.ts} +44 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +503 -55
- package/dist/internal/camera.d.ts +1 -1
- package/dist/internal/initial-view.d.ts +1 -1
- package/dist/internal/native-admission-bridge.js +8 -3
- package/dist/internal/native-admission.d.ts +15 -1
- package/dist/internal/native-admission.js +208 -21
- package/dist/internal/native-appearance.d.ts +1 -1
- package/dist/internal/session.d.ts +9 -2
- package/dist/internal/session.js +178 -21
- package/dist/internal/source-state.d.ts +1 -1
- package/ios/TFAdmissionBootstrap.mm +12 -6
- package/ios/TFAdmissionEngine.mm +4 -2
- package/package.json +3 -4
- package/docs/native-admission.md +0 -108
- package/docs/native-configuration.md +0 -210
- package/docs/native-interactions.md +0 -161
- package/docs/native-location.md +0 -148
package/dist/internal/session.js
CHANGED
|
@@ -19,6 +19,13 @@ var REFRESH_MARGIN_MS = 3e4;
|
|
|
19
19
|
var ROTATION_AGE_MS = 6 * 60 * 60 * 1e3;
|
|
20
20
|
var ROTATION_REQUEST_LIMIT = 1e4;
|
|
21
21
|
var FUTURE_ISSUE_SKEW_MS = 3e4;
|
|
22
|
+
var RETRY_INITIAL_MS = 1e3;
|
|
23
|
+
var RETRY_LIMIT_MS = 6e4;
|
|
24
|
+
var RETRY_AFTER_LIMIT_MS = 15 * 60 * 1e3;
|
|
25
|
+
var RETRY_AFTER_SECONDS = /^\d{1,10}$/u;
|
|
26
|
+
var QUOTA_CODES = /* @__PURE__ */ new Set(["COMMERCIAL_MAP_LIMIT_REACHED", "COMMERCIAL_SPENDING_LIMIT_REACHED"]);
|
|
27
|
+
var SERVICE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/u;
|
|
28
|
+
var SERVICE_CODE_BODY_LIMIT = 4096;
|
|
22
29
|
var MOBILE_CREDENTIAL = /^tf_public_[0-9a-f]{48}$/u;
|
|
23
30
|
var MAP_ID = /^map_[A-Za-z0-9_-]{16}$/u;
|
|
24
31
|
var CREDENTIAL_ID = /^key_[A-Za-z0-9_-]{8,80}$/u;
|
|
@@ -57,6 +64,14 @@ var SUCCESS_KEYS = /* @__PURE__ */ new Set([
|
|
|
57
64
|
"tilesetIds"
|
|
58
65
|
]);
|
|
59
66
|
var RESTART_KEYS = /* @__PURE__ */ new Set(["code", "error", "retryWithNewSession", "sessionId"]);
|
|
67
|
+
var TERMINAL_CODES = /* @__PURE__ */ new Set([
|
|
68
|
+
"NATIVE_SESSION_INPUT_INVALID",
|
|
69
|
+
"NATIVE_SESSION_CREDENTIAL_REJECTED",
|
|
70
|
+
"NATIVE_SESSION_MAP_MISMATCH",
|
|
71
|
+
"NATIVE_SESSION_MAP_NOT_FOUND",
|
|
72
|
+
"NATIVE_SESSION_QUOTA_EXCEEDED",
|
|
73
|
+
"NATIVE_SESSION_REJECTED"
|
|
74
|
+
]);
|
|
60
75
|
var ERROR_MESSAGES = {
|
|
61
76
|
NATIVE_SESSION_INPUT_INVALID: "Native session configuration is invalid.",
|
|
62
77
|
NATIVE_SESSION_RESPONSE_TOO_LARGE: "Native session response exceeded its byte limit.",
|
|
@@ -68,18 +83,22 @@ var ERROR_MESSAGES = {
|
|
|
68
83
|
NATIVE_SESSION_MAP_NOT_FOUND: "Native session Map was not found.",
|
|
69
84
|
NATIVE_SESSION_QUOTA_EXCEEDED: "Native session quota was exceeded.",
|
|
70
85
|
NATIVE_SESSION_REJECTED: "Native session request was rejected.",
|
|
86
|
+
NATIVE_SESSION_RATE_LIMITED: "Native session requests are rate limited.",
|
|
71
87
|
NATIVE_SESSION_UNAVAILABLE: "Native session service is unavailable.",
|
|
72
88
|
NATIVE_SESSION_REPLACED: "Native session binding was replaced.",
|
|
73
89
|
NATIVE_SESSION_DISPOSED: "Native session controller is disposed."
|
|
74
90
|
};
|
|
75
91
|
var HostedNativeSessionError = class extends Error {
|
|
76
|
-
constructor(code) {
|
|
92
|
+
constructor(code, reason) {
|
|
77
93
|
super(ERROR_MESSAGES[code]);
|
|
78
94
|
__publicField(this, "code");
|
|
79
95
|
__publicField(this, "kind");
|
|
80
96
|
this.name = "HostedNativeSessionError";
|
|
81
97
|
this.code = code;
|
|
82
98
|
this.kind = errorKind(code);
|
|
99
|
+
if (code === "NATIVE_SESSION_REJECTED" && typeof reason === "string" && SERVICE_CODE.test(reason)) {
|
|
100
|
+
this.reason = reason;
|
|
101
|
+
}
|
|
83
102
|
}
|
|
84
103
|
};
|
|
85
104
|
function createHostedNativeSessionController(input) {
|
|
@@ -189,7 +208,29 @@ function createHostedNativeSessionController(input) {
|
|
|
189
208
|
})
|
|
190
209
|
);
|
|
191
210
|
}
|
|
211
|
+
let terminal = null;
|
|
212
|
+
let failures = 0;
|
|
213
|
+
let retryAt = null;
|
|
214
|
+
let retryError = null;
|
|
215
|
+
function clearRetry() {
|
|
216
|
+
failures = 0;
|
|
217
|
+
retryAt = null;
|
|
218
|
+
retryError = null;
|
|
219
|
+
}
|
|
220
|
+
function delayRetry(error) {
|
|
221
|
+
let at;
|
|
222
|
+
try {
|
|
223
|
+
at = readClock();
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (retryAt === null || at >= retryAt) failures = Math.min(failures + 1, 16);
|
|
228
|
+
const backoff = Math.min(RETRY_INITIAL_MS * 2 ** (failures - 1), RETRY_LIMIT_MS);
|
|
229
|
+
retryAt = Math.max(retryAt ?? at, at + Math.max(backoff, retryAfterDelays.get(error) ?? 0));
|
|
230
|
+
retryError = safeError(error);
|
|
231
|
+
}
|
|
192
232
|
function rotateIfRequired(at) {
|
|
233
|
+
if (terminal) throw errorFrom(terminal);
|
|
193
234
|
if (!activeSession || binding.kind !== "hosted") return;
|
|
194
235
|
if (at - activeSession.startedAt < ROTATION_AGE_MS && activeSession.requestCount < ROTATION_REQUEST_LIMIT) {
|
|
195
236
|
return;
|
|
@@ -223,6 +264,7 @@ function createHostedNativeSessionController(input) {
|
|
|
223
264
|
return current;
|
|
224
265
|
}
|
|
225
266
|
async function ensureAuthority(session, at) {
|
|
267
|
+
if (terminal) throw errorFrom(terminal);
|
|
226
268
|
if (session.authority && session.authority.clockEpoch === clock.epoch && session.authority.validUntil - at > REFRESH_MARGIN_MS) {
|
|
227
269
|
return { authority: session.authority, session };
|
|
228
270
|
}
|
|
@@ -230,20 +272,28 @@ function createHostedNativeSessionController(input) {
|
|
|
230
272
|
if (binding.kind !== "hosted") {
|
|
231
273
|
throw new HostedNativeSessionError("NATIVE_SESSION_REPLACED");
|
|
232
274
|
}
|
|
275
|
+
if (retryAt !== null && retryError && at < retryAt) {
|
|
276
|
+
if (session.authority && session.authority.clockEpoch === clock.epoch && session.authority.validUntil > at) {
|
|
277
|
+
return { authority: session.authority, session };
|
|
278
|
+
}
|
|
279
|
+
throw errorFrom(retryError);
|
|
280
|
+
}
|
|
233
281
|
const operationBinding = binding;
|
|
234
282
|
const abort = new SessionAbortController();
|
|
235
283
|
operations.add(abort);
|
|
236
284
|
const operation = {
|
|
237
285
|
abort,
|
|
238
286
|
promise: Promise.resolve().then(async () => {
|
|
239
|
-
session.error = null;
|
|
240
|
-
publishSession(session, "loading");
|
|
241
|
-
if (abort.signal.aborted) throw abort.error();
|
|
242
287
|
try {
|
|
288
|
+
if (abort.signal.aborted) throw abort.error();
|
|
289
|
+
session.error = null;
|
|
290
|
+
publishSession(session, "loading");
|
|
291
|
+
if (abort.signal.aborted) throw abort.error();
|
|
243
292
|
const result = await bootstrap(session, operationBinding, abort, true, true);
|
|
244
293
|
if (abort.signal.aborted) throw abort.error();
|
|
245
294
|
result.session.authority = result.authority;
|
|
246
295
|
result.session.error = null;
|
|
296
|
+
if (binding === operationBinding) clearRetry();
|
|
247
297
|
if (activeSession === result.session) {
|
|
248
298
|
publishSession(result.session, "ready");
|
|
249
299
|
if (abort.signal.aborted) throw abort.error();
|
|
@@ -252,8 +302,26 @@ function createHostedNativeSessionController(input) {
|
|
|
252
302
|
} catch (error) {
|
|
253
303
|
const normalized = abort.signal.aborted ? abort.error() : normalizeError(error);
|
|
254
304
|
const target = latestSession(session);
|
|
305
|
+
if (normalized.kind === "terminal" && binding === operationBinding && !terminal) {
|
|
306
|
+
const failure = safeError(normalized);
|
|
307
|
+
terminal = failure;
|
|
308
|
+
const rejectedSession = activeSession;
|
|
309
|
+
if (rejectedSession) {
|
|
310
|
+
rejectedSession.authority = null;
|
|
311
|
+
rejectedSession.error = failure;
|
|
312
|
+
}
|
|
313
|
+
for (const pending of [...operations]) {
|
|
314
|
+
pending.abort(failure.code, failure.reason);
|
|
315
|
+
}
|
|
316
|
+
if (binding === operationBinding && rejectedSession) {
|
|
317
|
+
publishSession(rejectedSession, "error", failure);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (normalized.kind === "recoverable" && binding === operationBinding && !terminal) {
|
|
321
|
+
delayRetry(normalized);
|
|
322
|
+
}
|
|
255
323
|
if (normalized.code !== "NATIVE_SESSION_REPLACED" && normalized.code !== "NATIVE_SESSION_DISPOSED") {
|
|
256
|
-
target.error =
|
|
324
|
+
target.error = safeError(normalized);
|
|
257
325
|
publishSession(target, "error", target.error);
|
|
258
326
|
}
|
|
259
327
|
throw normalized;
|
|
@@ -279,20 +347,29 @@ function createHostedNativeSessionController(input) {
|
|
|
279
347
|
if (abort.signal.aborted) throw abort.error();
|
|
280
348
|
const expectedSurfaceId = session.authority?.public.surfaceId ?? session.requestedSurfaceId;
|
|
281
349
|
const response = await fetchResponse(hostedBinding, session, expectedSurfaceId, abort);
|
|
282
|
-
const source = await readBoundedResponse(response, abort);
|
|
350
|
+
const { source, size } = await readBoundedResponse(response, abort);
|
|
283
351
|
let body;
|
|
284
352
|
try {
|
|
285
353
|
body = source.length ? JSON.parse(source) : null;
|
|
286
354
|
} catch {
|
|
287
|
-
|
|
355
|
+
if (response.status === 201) {
|
|
356
|
+
throw new HostedNativeSessionError("NATIVE_SESSION_RESPONSE_JSON_INVALID");
|
|
357
|
+
}
|
|
358
|
+
body = null;
|
|
288
359
|
}
|
|
289
|
-
if (response.status === 409 &&
|
|
360
|
+
if (response.status === 409 && isExactRestart(body, session.sessionId)) {
|
|
361
|
+
if (!allowRestart) throw new HostedNativeSessionError("NATIVE_SESSION_UNAVAILABLE");
|
|
290
362
|
assertCurrentSession(session, hostedBinding);
|
|
291
363
|
const replacement = redirectSession(session, readClock());
|
|
292
364
|
if (abort.signal.aborted) throw abort.error();
|
|
293
365
|
return bootstrap(replacement, hostedBinding, abort, false, false);
|
|
294
366
|
}
|
|
295
|
-
if (response.status !== 201)
|
|
367
|
+
if (response.status !== 201) {
|
|
368
|
+
const failure = statusError(response.status, body, size);
|
|
369
|
+
const delay = retryAfterMs(response.headers, now);
|
|
370
|
+
if (delay !== null) retryAfterDelays.set(failure, delay);
|
|
371
|
+
throw failure;
|
|
372
|
+
}
|
|
296
373
|
if (!hasNoStore(response.headers.get("cache-control"))) {
|
|
297
374
|
throw new HostedNativeSessionError("NATIVE_SESSION_RESPONSE_INVALID");
|
|
298
375
|
}
|
|
@@ -365,6 +442,16 @@ function createHostedNativeSessionController(input) {
|
|
|
365
442
|
if (binding.kind === "direct" || !activeSession) {
|
|
366
443
|
return Object.freeze({ status: "idle", source: "direct", lifecycle });
|
|
367
444
|
}
|
|
445
|
+
if (terminal) {
|
|
446
|
+
return Object.freeze({
|
|
447
|
+
status: "error",
|
|
448
|
+
source: "hosted",
|
|
449
|
+
lifecycle,
|
|
450
|
+
mapId: binding.mapId,
|
|
451
|
+
sessionId: activeSession.sessionId,
|
|
452
|
+
error: terminal
|
|
453
|
+
});
|
|
454
|
+
}
|
|
368
455
|
if (activeSession.operation) {
|
|
369
456
|
return Object.freeze({
|
|
370
457
|
status: "loading",
|
|
@@ -416,6 +503,7 @@ function createHostedNativeSessionController(input) {
|
|
|
416
503
|
if (bindingGeneration !== expectedGeneration) {
|
|
417
504
|
throw new HostedNativeSessionError("NATIVE_SESSION_REPLACED");
|
|
418
505
|
}
|
|
506
|
+
if (terminal) throw errorFrom(terminal);
|
|
419
507
|
checkedAt = readClock();
|
|
420
508
|
if (resolved.authority.clockEpoch === clock.epoch && resolved.authority.validUntil > checkedAt) {
|
|
421
509
|
admittedAuthorities.set(resolved.authority.public, resolved.authority);
|
|
@@ -470,7 +558,7 @@ function createHostedNativeSessionController(input) {
|
|
|
470
558
|
}
|
|
471
559
|
},
|
|
472
560
|
transportBudget(authority) {
|
|
473
|
-
if (disposed || lifecycle !== "foreground") return 0;
|
|
561
|
+
if (disposed || terminal || lifecycle !== "foreground") return 0;
|
|
474
562
|
const stored = admittedAuthorities.get(authority);
|
|
475
563
|
if (!stored || stored.bindingGeneration !== bindingGeneration) return 0;
|
|
476
564
|
try {
|
|
@@ -489,6 +577,11 @@ function createHostedNativeSessionController(input) {
|
|
|
489
577
|
async resume() {
|
|
490
578
|
if (disposed) throw new HostedNativeSessionError("NATIVE_SESSION_DISPOSED");
|
|
491
579
|
lifecycle = "foreground";
|
|
580
|
+
if (terminal) {
|
|
581
|
+
const error = errorFrom(terminal);
|
|
582
|
+
publish(currentSnapshot());
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
492
585
|
if (binding.kind === "direct" || !activeSession) {
|
|
493
586
|
publish(currentSnapshot());
|
|
494
587
|
return;
|
|
@@ -511,9 +604,27 @@ function createHostedNativeSessionController(input) {
|
|
|
511
604
|
bindingGeneration += 1;
|
|
512
605
|
for (const operation of operations) operation.abort("NATIVE_SESSION_REPLACED");
|
|
513
606
|
binding = next;
|
|
607
|
+
terminal = null;
|
|
608
|
+
clearRetry();
|
|
514
609
|
activeSession = next.kind === "hosted" ? createSession(next.surfaceId, readClock()) : null;
|
|
515
610
|
publish(snapshotIdle(binding, activeSession, lifecycle));
|
|
516
611
|
},
|
|
612
|
+
retry() {
|
|
613
|
+
if (disposed) throw new HostedNativeSessionError("NATIVE_SESSION_DISPOSED");
|
|
614
|
+
if (!terminal && retryAt === null) return;
|
|
615
|
+
terminal = null;
|
|
616
|
+
clearRetry();
|
|
617
|
+
if (activeSession && !activeSession.operation) activeSession.error = null;
|
|
618
|
+
publish(currentSnapshot());
|
|
619
|
+
},
|
|
620
|
+
retryDelay() {
|
|
621
|
+
if (disposed || terminal || retryAt === null) return null;
|
|
622
|
+
try {
|
|
623
|
+
return Math.max(0, Math.ceil(retryAt - readClock()));
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
},
|
|
517
628
|
subscribe(listener) {
|
|
518
629
|
if (typeof listener !== "function") {
|
|
519
630
|
throw new HostedNativeSessionError("NATIVE_SESSION_INPUT_INVALID");
|
|
@@ -608,7 +719,8 @@ function parseSuccess(value, binding, expectedSessionId, expectedSurfaceId) {
|
|
|
608
719
|
const issuedAtMs = parseExactIso(value.issuedAt);
|
|
609
720
|
const serverTimeMs = parseExactIso(value.serverTime);
|
|
610
721
|
const expiresAtMs = parseExactIso(value.expiresAt);
|
|
611
|
-
if (issuedAtMs > serverTimeMs + FUTURE_ISSUE_SKEW_MS || expiresAtMs <= serverTimeMs || expiresAtMs <= issuedAtMs || expiresAtMs - issuedAtMs > MAX_GRANT_LIFETIME_MS ||
|
|
722
|
+
if (issuedAtMs > serverTimeMs + FUTURE_ISSUE_SKEW_MS || expiresAtMs <= serverTimeMs || expiresAtMs <= issuedAtMs || expiresAtMs - issuedAtMs > MAX_GRANT_LIFETIME_MS || // The local deadline is derived from serverTime, so this also bounds the transport budget.
|
|
723
|
+
expiresAtMs - serverTimeMs > MAX_GRANT_LIFETIME_MS) {
|
|
612
724
|
throw new HostedNativeSessionError("NATIVE_SESSION_RESPONSE_INVALID");
|
|
613
725
|
}
|
|
614
726
|
const authority = {
|
|
@@ -739,7 +851,7 @@ async function readBoundedResponse(response, abort) {
|
|
|
739
851
|
bytes.set(chunk, offset);
|
|
740
852
|
offset += chunk.byteLength;
|
|
741
853
|
}
|
|
742
|
-
return decodeUtf8(bytes);
|
|
854
|
+
return { source: decodeUtf8(bytes), size };
|
|
743
855
|
}
|
|
744
856
|
function decodeUtf8(bytes) {
|
|
745
857
|
let result = "";
|
|
@@ -787,18 +899,60 @@ function decodeUtf8(bytes) {
|
|
|
787
899
|
function hasNoStore(value) {
|
|
788
900
|
return typeof value === "string" && value.split(",").some((part) => part.trim().toLowerCase() === "no-store");
|
|
789
901
|
}
|
|
790
|
-
function statusError(status) {
|
|
902
|
+
function statusError(status, body, size) {
|
|
791
903
|
if (status === 401) return new HostedNativeSessionError("NATIVE_SESSION_CREDENTIAL_REJECTED");
|
|
792
904
|
if (status === 403) return new HostedNativeSessionError("NATIVE_SESSION_MAP_MISMATCH");
|
|
793
905
|
if (status === 404) return new HostedNativeSessionError("NATIVE_SESSION_MAP_NOT_FOUND");
|
|
794
|
-
if (status === 429)
|
|
795
|
-
|
|
796
|
-
|
|
906
|
+
if (status === 429) {
|
|
907
|
+
return new HostedNativeSessionError(
|
|
908
|
+
isRecord(body) && typeof body.code === "string" && QUOTA_CODES.has(body.code) ? "NATIVE_SESSION_QUOTA_EXCEEDED" : "NATIVE_SESSION_RATE_LIMITED"
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (status === 408 || status >= 500 && status <= 599) {
|
|
912
|
+
return new HostedNativeSessionError("NATIVE_SESSION_UNAVAILABLE");
|
|
913
|
+
}
|
|
914
|
+
if (status >= 400 && status <= 499) {
|
|
915
|
+
return new HostedNativeSessionError(
|
|
916
|
+
"NATIVE_SESSION_REJECTED",
|
|
917
|
+
size <= SERVICE_CODE_BODY_LIMIT && isRecord(body) && typeof body.code === "string" ? body.code : void 0
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
return new HostedNativeSessionError("NATIVE_SESSION_RESPONSE_INVALID");
|
|
921
|
+
}
|
|
922
|
+
function safeError(error) {
|
|
923
|
+
return Object.freeze(
|
|
924
|
+
error.reason === void 0 ? { code: error.code, kind: error.kind } : { code: error.code, kind: error.kind, reason: error.reason }
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
function errorFrom(error) {
|
|
928
|
+
return new HostedNativeSessionError(error.code, error.reason);
|
|
929
|
+
}
|
|
930
|
+
var retryAfterDelays = /* @__PURE__ */ new WeakMap();
|
|
931
|
+
function retryAfterMs(headers, now) {
|
|
932
|
+
let value;
|
|
933
|
+
try {
|
|
934
|
+
value = headers.get("retry-after");
|
|
935
|
+
} catch {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
if (typeof value !== "string" || value.length > 64) return null;
|
|
939
|
+
const text = value.trim();
|
|
940
|
+
let delay;
|
|
941
|
+
if (RETRY_AFTER_SECONDS.test(text)) delay = Number(text) * 1e3;
|
|
942
|
+
else {
|
|
943
|
+
const date = Date.parse(text);
|
|
944
|
+
if (!Number.isFinite(date)) return null;
|
|
945
|
+
try {
|
|
946
|
+
delay = date - readWallClock(now);
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return Math.min(Math.max(0, delay), RETRY_AFTER_LIMIT_MS);
|
|
797
952
|
}
|
|
798
953
|
function errorKind(code) {
|
|
799
954
|
if (code === "NATIVE_SESSION_DISPOSED" || code === "NATIVE_SESSION_REPLACED") return "cancelled";
|
|
800
|
-
|
|
801
|
-
return "terminal";
|
|
955
|
+
return TERMINAL_CODES.has(code) ? "terminal" : "recoverable";
|
|
802
956
|
}
|
|
803
957
|
function normalizeError(error, fallback = "NATIVE_SESSION_RESPONSE_INVALID") {
|
|
804
958
|
return error instanceof HostedNativeSessionError ? error : new HostedNativeSessionError(fallback);
|
|
@@ -821,12 +975,13 @@ async function cancelResponse(response) {
|
|
|
821
975
|
} catch {
|
|
822
976
|
}
|
|
823
977
|
}
|
|
824
|
-
var _aborted, _code, _listeners;
|
|
978
|
+
var _aborted, _code, _reason, _listeners;
|
|
825
979
|
var SessionAbortController = class {
|
|
826
980
|
constructor() {
|
|
827
981
|
__publicField(this, "signal");
|
|
828
982
|
__privateAdd(this, _aborted, false);
|
|
829
983
|
__privateAdd(this, _code, "NATIVE_SESSION_REPLACED");
|
|
984
|
+
__privateAdd(this, _reason);
|
|
830
985
|
__privateAdd(this, _listeners, /* @__PURE__ */ new Set());
|
|
831
986
|
const isAborted = () => __privateGet(this, _aborted);
|
|
832
987
|
this.signal = Object.freeze({
|
|
@@ -841,9 +996,10 @@ var SessionAbortController = class {
|
|
|
841
996
|
}
|
|
842
997
|
});
|
|
843
998
|
}
|
|
844
|
-
abort(code) {
|
|
999
|
+
abort(code, reason) {
|
|
845
1000
|
if (__privateGet(this, _aborted)) return;
|
|
846
1001
|
__privateSet(this, _code, code);
|
|
1002
|
+
__privateSet(this, _reason, reason);
|
|
847
1003
|
__privateSet(this, _aborted, true);
|
|
848
1004
|
for (const listener of [...__privateGet(this, _listeners)]) {
|
|
849
1005
|
try {
|
|
@@ -854,11 +1010,12 @@ var SessionAbortController = class {
|
|
|
854
1010
|
__privateGet(this, _listeners).clear();
|
|
855
1011
|
}
|
|
856
1012
|
error() {
|
|
857
|
-
return new HostedNativeSessionError(__privateGet(this, _code));
|
|
1013
|
+
return new HostedNativeSessionError(__privateGet(this, _code), __privateGet(this, _reason));
|
|
858
1014
|
}
|
|
859
1015
|
};
|
|
860
1016
|
_aborted = new WeakMap();
|
|
861
1017
|
_code = new WeakMap();
|
|
1018
|
+
_reason = new WeakMap();
|
|
862
1019
|
_listeners = new WeakMap();
|
|
863
1020
|
function raceWithAbort(promise, controller) {
|
|
864
1021
|
if (controller.signal.aborted) return Promise.reject(controller.error());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TileflowNativeSourceState } from '@tileflow/core/native';
|
|
2
|
-
import {
|
|
2
|
+
import { x as MapSourceState } from '../contract-BWT1jxqg.js';
|
|
3
3
|
import '@maplibre/maplibre-react-native';
|
|
4
4
|
import 'react';
|
|
5
5
|
import 'react-native';
|
|
@@ -102,11 +102,13 @@ static NSTextCheckingResult *TFBootstrapMatch(NSString *value, NSString *pattern
|
|
|
102
102
|
context.endpoint = clean; context.credential = credential; context.lastRequest = sequence;
|
|
103
103
|
TFBootstrapWork *work = [TFBootstrapWork new];
|
|
104
104
|
work.identifier = requestId; work.context = context; work.alive = [TFBootstrapFlag new];
|
|
105
|
-
|
|
105
|
+
// Well inside the 30 s ticket and batch deadlines, so a stalled renewal fails its batch
|
|
106
|
+
// instead of letting the context retire under it.
|
|
107
|
+
work.deadline = [self.scheduler nowMs] + 10000; work.completion = completion;
|
|
106
108
|
context.pending[requestId] = work; self.pendingCount++;
|
|
107
|
-
work.timer = [self.scheduler after:
|
|
109
|
+
work.timer = [self.scheduler after:10000 perform:^{ [self finish:work reply:nil]; }];
|
|
108
110
|
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:clean]
|
|
109
|
-
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:
|
|
111
|
+
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData timeoutInterval:10];
|
|
110
112
|
request.HTTPMethod = @"POST"; request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding];
|
|
111
113
|
request.HTTPShouldHandleCookies = NO;
|
|
112
114
|
[request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
|
|
@@ -115,13 +117,17 @@ static NSTextCheckingResult *TFBootstrapMatch(NSString *value, NSString *pattern
|
|
|
115
117
|
@try {
|
|
116
118
|
work.network = [self.network start:request mayStart:^BOOL { return [self mayStart:work]; } completion:^(NSHTTPURLResponse *response, NSData *bytes) {
|
|
117
119
|
[self.scheduler enqueue:^{
|
|
118
|
-
NSString *cacheControl = @"";
|
|
120
|
+
NSString *cacheControl = @"", *retryAfter = @"";
|
|
119
121
|
for (id name in response.allHeaderFields) {
|
|
120
|
-
if ([name isKindOfClass:NSString.class]
|
|
122
|
+
if (![name isKindOfClass:NSString.class]) continue;
|
|
123
|
+
if ([(NSString *)name caseInsensitiveCompare:@"Cache-Control"] == NSOrderedSame) cacheControl = response.allHeaderFields[name];
|
|
124
|
+
if ([(NSString *)name caseInsensitiveCompare:@"Retry-After"] == NSOrderedSame) retryAfter = response.allHeaderFields[name];
|
|
121
125
|
}
|
|
126
|
+
// An oversized Retry-After is ignored rather than failing the reply.
|
|
127
|
+
if (!TFBootstrapString(retryAfter, 64)) retryAfter = @"";
|
|
122
128
|
BOOL valid = [self mayStart:work] && [response isKindOfClass:NSHTTPURLResponse.class] && response.statusCode >= 100 && response.statusCode <= 599 &&
|
|
123
129
|
TFBootstrapString(cacheControl, 1024) && [bytes isKindOfClass:NSData.class] && bytes.length <= 65536;
|
|
124
|
-
NSDictionary *reply = valid ? @{@"status": @(response.statusCode), @"cacheControl": cacheControl, @"bodyBase64": [bytes base64EncodedStringWithOptions:0]} : nil;
|
|
130
|
+
NSDictionary *reply = valid ? @{@"status": @(response.statusCode), @"cacheControl": cacheControl, @"retryAfter": retryAfter, @"bodyBase64": [bytes base64EncodedStringWithOptions:0]} : nil;
|
|
125
131
|
[self finish:work reply:reply];
|
|
126
132
|
}];
|
|
127
133
|
}];
|
package/ios/TFAdmissionEngine.mm
CHANGED
|
@@ -168,8 +168,10 @@ static BOOL TFGrantShape(NSString *value) {
|
|
|
168
168
|
work.response = response; work.failure = failure; work.delegate = delegate; work.releaseReservation = release;
|
|
169
169
|
context.work[work.identifier] = work; [context.order addObject:work.identifier];
|
|
170
170
|
work.timer = [self.scheduler after:30000 perform:^{
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
// A slow renewal fails only this request. JavaScript still answers its batch, and the
|
|
172
|
+
// batch deadline alone retires a context whose JavaScript stopped answering.
|
|
173
|
+
if ([context.batchTickets containsObject:work.identifier]) [self event:context kind:@"cancel" fields:@{@"tickets": @[work.identifier]}];
|
|
174
|
+
[self fail:work];
|
|
173
175
|
}];
|
|
174
176
|
[self.scheduler enqueue:^{ [self drain:context]; }];
|
|
175
177
|
}];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tileflow/react-native",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Tileflow maps, camera control and semantic interactions for React Native.",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
|
-
"docs",
|
|
22
21
|
"android/build.gradle",
|
|
23
22
|
"android/src/main",
|
|
24
23
|
"ios/*.h",
|
|
@@ -36,8 +35,8 @@
|
|
|
36
35
|
}
|
|
37
36
|
},
|
|
38
37
|
"dependencies": {
|
|
39
|
-
"@tileflow/core": ">=0.1.0-alpha.
|
|
40
|
-
"@tileflow/interactions": ">=0.1.0-alpha.
|
|
38
|
+
"@tileflow/core": ">=0.1.0-alpha.43 <0.1.0-beta.0",
|
|
39
|
+
"@tileflow/interactions": ">=0.1.0-alpha.7 <0.1.0-beta.0"
|
|
41
40
|
},
|
|
42
41
|
"peerDependencies": {
|
|
43
42
|
"@maplibre/maplibre-react-native": "11.5.0",
|
package/docs/native-admission.md
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# Private native resource admission
|
|
2
|
-
|
|
3
|
-
This guide describes the private native networking boundary used by the mounted Tileflow `Map` in `@tileflow/react-native`. The transport, session controller, resource catalog, configuration reader and native bridge are **not a public API**. The package root exports the Tileflow `Map` and public Map types only; it does not export grants, credentials, session objects, admission handles, native renderer handles or request interception controls.
|
|
4
|
-
|
|
5
|
-
The mounted owner prepares a **bounded Style/TileJSON resource closure** before native rendering. It covers the current owned style document, one TileJSON edge per tile source, tile templates, sprite leaves, glyph templates, native-v1 managed font faces and supported base assets represented by the current contracts. It does not recursively trust arbitrary remote graphs. Ambiguous, unsafe, oversized or incomplete protected graphs fail closed, while foreign/unowned resources remain unchanged.
|
|
6
|
-
|
|
7
|
-
## Exact native dependencies
|
|
8
|
-
|
|
9
|
-
The source targets React 19.2.0, React Native 0.83.10, MapLibre React Native 11.5.0, MapLibre Native Android 13.6.1 and MapLibre Native iOS 6.31.0. Android's HTTP dependency is OkHttp 4.12.0. These pins and the existence of source tests are **not evidence** of a successful build, device run, Hosted service or app-store acceptance; qualify the exact revision in the intended host.
|
|
10
|
-
|
|
11
|
-
Android's library build uses the host's Android Gradle/Kotlin plugins and SDK settings. `react-native.config.cjs` autolinks `TileflowNativeAdmissionPackage`, which registers the private admission, document, configuration and surface modules. The library depends on the autolinked `:maplibre_maplibre-react-native` project and rejects conflicting MapLibre Native versions rather than selecting another version silently.
|
|
12
|
-
|
|
13
|
-
On iOS, the `TileflowNativeAdmission` pod depends on the exact React and MapLibre React Native pods. MapLibre Native itself is the existing pinned Swift Package Manager product, not a second MapLibre CocoaPod. Retain the host's React Native post-install work and MapLibre setup, then invoke:
|
|
14
|
-
|
|
15
|
-
```ruby
|
|
16
|
-
$MLRN.post_install(installer)
|
|
17
|
-
tileflow_native_admission_post_install(installer)
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
The helper requires the MapLibre distribution reference with `exactVersion` 6.31.0 and reuses that product for the Tileflow pod/test targets. It does not add another SDK or change the pin.
|
|
21
|
-
|
|
22
|
-
## Installation ownership
|
|
23
|
-
|
|
24
|
-
Tileflow owns the MapLibre networking seam in explicit installation order. Android captures the previous `ModuleProvider`, wraps `createHttpRequest()` and delegates unowned traffic to the previous provider. iOS adds `TFNativeAdmissionURLProtocol` only to MapLibre's session configuration and uses an independent internal URLSession; it never registers an application-global protocol.
|
|
25
|
-
|
|
26
|
-
Installation/removal are acknowledged operations. Partial installation rolls back only state still owned by that attempt. A later incompatible provider/configuration replacement makes protected work fail closed; removal never overwrites a later owner. This is not a guarantee for arbitrary third-party mutation chains.
|
|
27
|
-
|
|
28
|
-
The process-level installation may be shared by mounted Maps, but that is the only shared ownership. Each real Map owns a distinct source generation, Hosted binding/session/controller, native context, catalog, queue, tickets and callbacks. One Map's retirement or cleanup failure cannot spend, remove or reuse another Map's authority.
|
|
29
|
-
|
|
30
|
-
## Mounted Map lifecycle
|
|
31
|
-
|
|
32
|
-
A native Map is created only after source resolution and context acknowledgement. If the resolved manifest uses Hosted session delivery, application configuration must first yield a binding whose canonical API origin exactly matches the manifest. Non-session delivery does not read the mobile credential or bootstrap Hosted authority.
|
|
33
|
-
|
|
34
|
-
Theme transactions keep the same native Map, context, session and camera. Resource additions are append-only and become usable only after native `extendContext` acknowledgement. Glyph templates may extend their finite font-stack allowlist across themes by an acknowledged union; the template URL, origin, class and scope cannot change. A logical source/Map replacement retires the previous context before opening the next one.
|
|
35
|
-
|
|
36
|
-
Retirement is idempotent and cancels queue, JavaScript wait, admitted work, document acquisition and late native completions. Removal acknowledgements are retryable. Strict Mode unmount/remount therefore cannot revive the old generation or share its context with the replacement.
|
|
37
|
-
|
|
38
|
-
## Resource identity and projection
|
|
39
|
-
|
|
40
|
-
Every protected resource is canonical HTTPS and belongs to one explicit class: `style`, `tilejson`, `tile`, `sprite`, `glyph` or `font`. Tile and TileJSON entries also preserve exact tileset identity. Tile templates use only the bounded native placeholder grammar. Glyph templates require both `{fontstack}` and `{range}` and a finite exact font-stack set.
|
|
41
|
-
|
|
42
|
-
Style preparation resolves relative URLs through Core's native URL policy. A style may discover at most one bounded TileJSON document for a declared tile source; TileJSON cannot recursively point to another TileJSON. Sprite base URLs are admitted through their concrete JSON/PNG 1x/2x leaves before the style receives its transient context discriminator. Native-v1 font-face arrays preserve family/style/weight attributes while only their admitted URL is rewritten.
|
|
43
|
-
|
|
44
|
-
Owned URLs must match the exact Hosted origin/path rules and the session policy's scope/tileset set. A resource on a foreign origin remains unmodified. An URL on an owned origin but outside a known resource path fails closed instead of inheriting authority merely because its origin matches.
|
|
45
|
-
|
|
46
|
-
The non-secret `__tf_native_context` discriminator selects one Map context. It is stripped before HTTP and never conveys authority. Unknown, malformed, stale, duplicate or retired reserved context values are rejected rather than delegated as third-party traffic. Other URL identity is preserved.
|
|
47
|
-
|
|
48
|
-
## Admission accounting
|
|
49
|
-
|
|
50
|
-
Every eligible protected request produces one logical controller `acquire()` result. A bounded bridge batch may carry several tickets but never shares one acquire, preallocates grants, counts chunks or maintains a native commercial counter. Direct/non-session contexts delegate without commercial acquisition.
|
|
51
|
-
|
|
52
|
-
The controller preserves the 10,000-request and six-hour rotation semantics: the 10,000th acquisition remains in its session and the next rotates. Bootstrap/refresh are single-flight where allowed. Native cancellation does not refund a logical acquisition already made. HTTP response observation does not confirm or undo server-owned commercial completion.
|
|
53
|
-
|
|
54
|
-
`X-Tileflow-Native-Grant` is the only protected-resource grant carrier. It is attached only after resource/context/session policy checks and never enters query strings, URLs, cache keys, public state, events, errors or logs.
|
|
55
|
-
|
|
56
|
-
## Independent bootstrap and expiry
|
|
57
|
-
|
|
58
|
-
Bootstrap uses an independent OkHttp client or ephemeral URLSession channel. It sends the bounded session-start POST to the configured canonical API origin and carries the publishable mobile credential only in `X-Tileflow-Mobile-Client`. Cookies, ambient credentials and automatic redirects are disabled.
|
|
59
|
-
|
|
60
|
-
Each context binds its validated bootstrap endpoint/credential. Retiring that context cancels its bootstrap without cancelling another Map. TypeScript alone parses session authority and makes admission decisions.
|
|
61
|
-
|
|
62
|
-
The controller's private transport budget remains anchored to its existing local `validUntil`. Native never reconstructs a fresh lifetime from server timestamps. The budget is bounded by the earlier ticket-enqueue time, the native wait deadline and a safety margin, and is rechecked immediately before network start and redirects. Backgrounding, clock rollback, source replacement, disposal and expiry invalidate stale authority.
|
|
63
|
-
|
|
64
|
-
No synchronous network hook, main thread or OkHttp interceptor waits for JavaScript. Suspended JavaScript therefore cannot authorize stale protected work.
|
|
65
|
-
|
|
66
|
-
## Redirects and observation
|
|
67
|
-
|
|
68
|
-
Protected redirects are handled by the adapter, not by automatic credential forwarding. Same-origin targets must still satisfy catalog, path/class, tileset, context and ticket validity. Cross-origin, different-port, uncataloged or conflicting-context targets receive no forwarded grant. A redirect does not consume another admission for the same ticket.
|
|
69
|
-
|
|
70
|
-
iOS does not rely on MapLibre forwarding `didReceiveResponse` to a configuration delegate. The adapter observes its own response. Native observation events contain only bounded identifiers/status and are private; they are not renderer readiness or commercial completion events.
|
|
71
|
-
|
|
72
|
-
## Renderer readiness
|
|
73
|
-
|
|
74
|
-
Admission success is not Map readiness. The private surface adapter requires current style acceptance, committed positive native layout and a fully rendered native map/frame after that layout. Background, layout loss, camera gesture/command epochs and style replacement invalidate the evidence. Resume re-arms native evidence; JavaScript animation frames and sleeps are not readiness proof.
|
|
75
|
-
|
|
76
|
-
Camera commands carry private monotonically correlated tokens. A successful public controller command is acknowledged only after the native target is applied, not merely queued by the upstream React Native bridge. User gestures are classified from native gesture reasons and remain separate from programmatic commands.
|
|
77
|
-
|
|
78
|
-
## Fixed transport bounds
|
|
79
|
-
|
|
80
|
-
| Boundary | Limit |
|
|
81
|
-
| -------------------------------------- | --------------------------------------------: |
|
|
82
|
-
| Live Map contexts per installation | 16 |
|
|
83
|
-
| Catalog resources per context | 128 |
|
|
84
|
-
| Native engine ingress per installation | 2,048 |
|
|
85
|
-
| Protected work per context | 128 |
|
|
86
|
-
| Tickets per bridge batch | 8 |
|
|
87
|
-
| Resource URL | 2,048 characters including discriminator room |
|
|
88
|
-
| Admission bridge payload | 524,288 bytes |
|
|
89
|
-
| Grant | 24,576 characters |
|
|
90
|
-
| Protected transport deadline | 30 seconds from enqueue |
|
|
91
|
-
| Validity safety margin | 1 second |
|
|
92
|
-
| Protected redirects | 3 |
|
|
93
|
-
| Protected response body | 8,388,608 bytes |
|
|
94
|
-
| Pending bootstrap bridge work | 32 |
|
|
95
|
-
| Bootstrap request / response | 2,048 / 65,536 bytes |
|
|
96
|
-
| Bootstrap deadline | 30 seconds |
|
|
97
|
-
|
|
98
|
-
Cancellation retains transport capacity until the corresponding native cleanup boundary. Capacity bounds are transport safety limits, not product quotas or billing counters.
|
|
99
|
-
|
|
100
|
-
## Application configuration
|
|
101
|
-
|
|
102
|
-
The publishable mobile application credential and trusted API origin are configured through native application configuration only. They are not component props or JavaScript globals. See [Native application configuration](./native-configuration.md) for exact iOS/Android keys, credential grammar, origin normalization, rotation/revocation and the end-user threat-model boundary.
|
|
103
|
-
|
|
104
|
-
## Local qualification
|
|
105
|
-
|
|
106
|
-
The source-checkout [mounted Map harness](../harness/README.md) uses the real public Tileflow component in an existing development host and is excluded from package exports/packed files. It creates no service, runner or deployment.
|
|
107
|
-
|
|
108
|
-
Before accepting a revision, qualify package build/types/tests, the exact Android and iOS native test suites, CocoaPods/SPM integration, two concurrent Maps, Strict Mode replay, source replacement during each asynchronous phase, theme success/rollback, controlled camera gestures/commands, background/resume, readiness ordering, redirects, ownership mutation and teardown retries. Inspect only secret-free fixture assertions and public events. Authored source and tests are not evidence that these checks passed.
|