@assembly-js/app-foundation 0.5.1 → 0.5.3
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/CHANGELOG.md +6 -0
- package/dist/lib-client.d.ts +1 -0
- package/dist/lib-client.js +58 -9
- package/dist/paint-beacon.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.5.3
|
|
23
|
+
- [fix] Sign-in renewal is now bounded to one attempt per 30 seconds per tab, so a burst of expired requests renews once and a late response can no longer undo a fresh renewal.
|
|
24
|
+
|
|
25
|
+
## 0.5.2
|
|
26
|
+
- [fix] The builder preview no longer counts a page marked as an error document in its server HTML as the app having painted, even when it streams in before the page finishes hydrating.
|
|
27
|
+
|
|
22
28
|
## 0.5.1
|
|
23
29
|
- [guidance] The build agent now detects PDF form fields with `detectPdfFields` and gives XFA, text-only and empty PDFs their own UI instead of reporting "no fields".
|
|
24
30
|
|
package/dist/lib-client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
|
|
2
|
+
export declare function resetRenewalStateForTests(): void;
|
|
2
3
|
export declare function reexchangeBearer(): Promise<boolean>;
|
|
3
4
|
export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
4
5
|
export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
|
package/dist/lib-client.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
-
import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
8
|
+
import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
9
|
+
import { readCookie } from './origin-bootstrap.js';
|
|
9
10
|
import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
|
|
10
11
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
11
12
|
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
@@ -13,18 +14,65 @@ const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
|
13
14
|
// once, and uncoordinated renewals would race each other for the token — the
|
|
14
15
|
// first mints the bearer and the losers surface their original 401 even though
|
|
15
16
|
// a valid bearer was just installed. All concurrent callers share this one
|
|
16
|
-
// promise instead
|
|
17
|
+
// promise instead.
|
|
17
18
|
let renewalInFlight = null;
|
|
19
|
+
// After a renewal POST settles, its verdict stands for this long so a burst of
|
|
20
|
+
// 401s (and a failing route that keeps answering 401) costs one mint, not one
|
|
21
|
+
// per response.
|
|
22
|
+
const RENEWAL_WINDOW_MS = 30_000;
|
|
23
|
+
let lastVerdict = null;
|
|
24
|
+
let probeEverSet = false;
|
|
25
|
+
const probeIsSet = () => {
|
|
26
|
+
const set = readCookie(COOKIE_PROBE_NAME) !== null;
|
|
27
|
+
if (set)
|
|
28
|
+
probeEverSet = true;
|
|
29
|
+
return set;
|
|
30
|
+
};
|
|
31
|
+
// The probe is gone by the time the first 401 triggers a renewal, so it has to
|
|
32
|
+
// be seen while the page still holds a valid bearer: at load and before each
|
|
33
|
+
// request.
|
|
34
|
+
probeIsSet();
|
|
35
|
+
// Test seam: module state otherwise outlives a single test.
|
|
36
|
+
export function resetRenewalStateForTests() {
|
|
37
|
+
renewalInFlight = null;
|
|
38
|
+
lastVerdict = null;
|
|
39
|
+
probeEverSet = false;
|
|
40
|
+
}
|
|
41
|
+
// A late 401 response carries the proxy's cookie deletion and can erase the
|
|
42
|
+
// bearer a sibling call just renewed. The probe is cleared together with the
|
|
43
|
+
// bearer and is readable from script (the bearer is HttpOnly), so its absence
|
|
44
|
+
// is the only signal that the cached success no longer holds.
|
|
45
|
+
function verdictInWindow() {
|
|
46
|
+
if (!lastVerdict || Date.now() - lastVerdict.at >= RENEWAL_WINDOW_MS) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (lastVerdict.ok && lastVerdict.probeSeen && !probeIsSet())
|
|
50
|
+
return null;
|
|
51
|
+
return lastVerdict.ok;
|
|
52
|
+
}
|
|
18
53
|
// Silently renews the bearer cookie after a 60-minute expiry. Asks the bridge
|
|
19
54
|
// for the freshest session token (the parent keeps it alive) and posts it to the
|
|
20
55
|
// re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
|
|
21
|
-
// page reload. Returns true iff a fresh bearer
|
|
22
|
-
//
|
|
23
|
-
//
|
|
56
|
+
// page reload. Returns true iff a fresh bearer is in place. Inside the window
|
|
57
|
+
// after a POST it answers from that POST's outcome without sending another, so
|
|
58
|
+
// authFetch gets false for the window after a failed renewal instead of a fresh
|
|
59
|
+
// mint per 401. A no_token outcome sends nothing and starts no window. This is
|
|
60
|
+
// the only place app code touches the raw session token, to re-mint and keep the
|
|
61
|
+
// URL aligned with the cookie; it is never sent to the platform API.
|
|
24
62
|
export function reexchangeBearer() {
|
|
25
63
|
if (renewalInFlight)
|
|
26
64
|
return renewalInFlight;
|
|
27
|
-
|
|
65
|
+
probeIsSet();
|
|
66
|
+
const cached = verdictInWindow();
|
|
67
|
+
if (cached !== null)
|
|
68
|
+
return Promise.resolve(cached);
|
|
69
|
+
const renewal = renewBearer()
|
|
70
|
+
.then(({ ok, sent }) => {
|
|
71
|
+
probeIsSet();
|
|
72
|
+
lastVerdict = sent ? { at: Date.now(), ok, probeSeen: probeEverSet } : null;
|
|
73
|
+
return ok;
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
28
76
|
renewalInFlight = null;
|
|
29
77
|
});
|
|
30
78
|
renewalInFlight = renewal;
|
|
@@ -52,7 +100,7 @@ async function renewBearer() {
|
|
|
52
100
|
}
|
|
53
101
|
if (!token) {
|
|
54
102
|
logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
|
|
55
|
-
return false;
|
|
103
|
+
return { ok: false, sent: false };
|
|
56
104
|
}
|
|
57
105
|
try {
|
|
58
106
|
const res = await fetch(REEXCHANGE_PATH, {
|
|
@@ -91,11 +139,11 @@ async function renewBearer() {
|
|
|
91
139
|
// A history update failure must not invalidate the cookie just renewed.
|
|
92
140
|
}
|
|
93
141
|
}
|
|
94
|
-
return res.ok;
|
|
142
|
+
return { ok: res.ok, sent: true };
|
|
95
143
|
}
|
|
96
144
|
catch {
|
|
97
145
|
logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
|
|
98
|
-
return false;
|
|
146
|
+
return { ok: false, sent: true };
|
|
99
147
|
}
|
|
100
148
|
}
|
|
101
149
|
// Whether a denial is worth a renew-and-retry. A 401 is an authentication
|
|
@@ -116,6 +164,7 @@ function shouldRenewBearer(res) {
|
|
|
116
164
|
// once via reexchangeBearer and retries; a genuine forbidden just returns.
|
|
117
165
|
export async function authFetch(input, init) {
|
|
118
166
|
const headers = { ...(init?.headers ?? {}), [APP_CSRF_HEADER]: '1' };
|
|
167
|
+
probeIsSet();
|
|
119
168
|
const res = await fetch(input, { ...init, headers });
|
|
120
169
|
if (!shouldRenewBearer(res)) {
|
|
121
170
|
return res;
|
package/dist/paint-beacon.js
CHANGED
|
@@ -17,8 +17,12 @@ let fired = false;
|
|
|
17
17
|
// the schedule latch, so the boundary can re-install it once the app has
|
|
18
18
|
// rendered in its place.
|
|
19
19
|
const ERROR_DOCUMENT_FLAG = '__assemblyErrorDocument';
|
|
20
|
+
// The same pages also carry this attribute in their server HTML. A page streamed
|
|
21
|
+
// in under Suspense can reach load before React hydrates it and sets the flag.
|
|
22
|
+
const ERROR_DOCUMENT_SELECTOR = '[data-assembly-error-document]';
|
|
20
23
|
function isErrorDocument() {
|
|
21
|
-
return window[ERROR_DOCUMENT_FLAG] === true
|
|
24
|
+
return (window[ERROR_DOCUMENT_FLAG] === true ||
|
|
25
|
+
document.querySelector(ERROR_DOCUMENT_SELECTOR) !== null);
|
|
22
26
|
}
|
|
23
27
|
function firePaintBeacon() {
|
|
24
28
|
if (fired)
|