@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.
@@ -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 = Object.freeze({ code: normalized.code, kind: normalized.kind });
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
- throw new HostedNativeSessionError("NATIVE_SESSION_RESPONSE_JSON_INVALID");
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 && allowRestart && isExactRestart(body, session.sessionId)) {
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) throw statusError(response.status);
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 || 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) return new HostedNativeSessionError("NATIVE_SESSION_QUOTA_EXCEEDED");
795
- if (status === 503) return new HostedNativeSessionError("NATIVE_SESSION_UNAVAILABLE");
796
- return new HostedNativeSessionError("NATIVE_SESSION_REJECTED");
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
- if (code === "NATIVE_SESSION_UNAVAILABLE") return "recoverable";
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 { v as MapSourceState } from '../contract-By3URNxS.js';
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
- work.deadline = [self.scheduler nowMs] + 30000; work.completion = completion;
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:30000 perform:^{ [self finish:work reply:nil]; }];
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:30];
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] && [(NSString *)name caseInsensitiveCompare:@"Cache-Control"] == NSOrderedSame) cacheControl = response.allHeaderFields[name];
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
  }];
@@ -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
- if ([context.batchTickets containsObject:work.identifier]) [self retire:context.identifier];
172
- else [self fail:work];
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.6",
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 <0.1.0-beta.0",
40
- "@tileflow/interactions": ">=0.1.0-alpha.6 <0.1.0-beta.0"
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",
@@ -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.