@geniee-inc/sfa-kit 1.17.0 → 1.19.0
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/GUIDE.md +34 -14
- package/package.json +1 -1
- package/src/bin/sfa-kit-bundle.js +16 -8
- package/src/bundle.d.ts +1 -2
- package/src/index.d.ts +1 -1
- package/src/lib/auth/resolve-api-base.d.ts +6 -4
- package/src/lib/auth/resolve-api-base.js +24 -5
- package/src/lib/auth/useEmbedSession.d.ts +2 -1
- package/src/lib/auth/useEmbedSession.js +196 -26
- package/src/lib/bundle/build-static-bundle.d.ts +37 -11
- package/src/lib/bundle/build-static-bundle.js +75 -35
- package/src/lib/bundle/request-signature.d.ts +13 -1
- package/src/lib/bundle/request-signature.js +21 -6
- package/src/lib/components/FieldRenderer.d.ts +2 -1
- package/src/lib/components/FieldRenderer.js +32 -19
- package/src/lib/components/RecordList.js +10 -0
- package/src/lib/components/RecordView.js +27 -7
- package/src/lib/components/fieldDisplayText.d.ts +11 -0
- package/src/lib/components/fieldDisplayText.js +51 -0
- package/src/lib/hooks/useFieldTypes.d.ts +5 -0
- package/src/lib/hooks/useFieldTypes.js +5 -0
- package/src/lib/hooks/useObjectCatalog.d.ts +6 -0
- package/src/lib/hooks/useObjectCatalog.js +6 -0
- package/src/lib/hooks/useRecord.d.ts +20 -0
- package/src/lib/hooks/useRecord.js +53 -0
- package/src/lib/provider/SfaClient.d.ts +13 -0
- package/src/lib/provider/SfaClient.js +25 -8
- package/src/lib/provider/SfaProvider.d.ts +2 -2
- package/src/lib/provider/SfaProvider.js +1 -1
- package/src/lib/provider/types.d.ts +12 -1
- package/src/lib/signing/compute-signature.d.ts +4 -2
- package/src/lib/signing/compute-signature.js +4 -2
- package/src/lib/signing/create-manifest.d.ts +8 -6
- package/src/lib/signing/create-manifest.js +8 -6
- package/src/lib/signing/index.d.ts +1 -1
- package/src/lib/signing/types.d.ts +33 -4
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
- package/src/signing.d.ts +2 -2
|
@@ -21,12 +21,23 @@ const CODE_TIMEOUT_SECONDS = 15;
|
|
|
21
21
|
/** Consecutive failed attempts to obtain a usable session before giving up. */
|
|
22
22
|
const MAX_RECOVERY_ATTEMPTS = 4;
|
|
23
23
|
/**
|
|
24
|
-
* A
|
|
25
|
-
* recovery run. Without it, a token Keystone happily mints but the API
|
|
26
|
-
* rejecting loops forever, because every exchange succeeds and resets
|
|
24
|
+
* A rejection landing within this window of the run's start belongs to the
|
|
25
|
+
* same recovery run. Without it, a token Keystone happily mints but the API
|
|
26
|
+
* keeps rejecting loops forever, because every exchange succeeds and resets
|
|
27
|
+
* the retry counters.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: only rejections of the token currently held are counted — a reply
|
|
30
|
+
* about a token already replaced is dropped before it gets here. The window
|
|
31
|
+
* therefore bounds a genuine loop, and no longer trips on the stragglers of a
|
|
32
|
+
* session that is recovering perfectly well.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: a backstop for a fast loop, not a precise detector. Separating a loop
|
|
35
|
+
* from an endpoint answering 401 where it means 403 needs to know whether any
|
|
36
|
+
* request *succeeded* with the replacement token, which nothing reports — the
|
|
37
|
+
* client only calls back on failure.
|
|
27
38
|
*/
|
|
28
39
|
const AUTH_ERROR_WINDOW_SECONDS = 30;
|
|
29
|
-
/**
|
|
40
|
+
/** Rejections of the current token allowed inside AUTH_ERROR_WINDOW_SECONDS. */
|
|
30
41
|
const MAX_AUTH_ERROR_CYCLES = 3;
|
|
31
42
|
/** An exchange still unsettled after this long is treated as abandoned. */
|
|
32
43
|
const STALE_EXCHANGE_SECONDS = 20;
|
|
@@ -84,6 +95,8 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
84
95
|
const [phase, setPhase] = useState({ step: 'waiting_for_code' });
|
|
85
96
|
const refreshTimerRef = useRef(null);
|
|
86
97
|
const retryTimerRef = useRef(null);
|
|
98
|
+
/** ホストから最初のコードが届くのを待つタイマー。届いた時点で止める。 */
|
|
99
|
+
const codeTimeoutRef = useRef(null);
|
|
87
100
|
const exchangingRef = useRef(false);
|
|
88
101
|
const exchangeStartedAtRef = useRef(0);
|
|
89
102
|
/** 追い越された交換の結果で新しいセッションを上書きしないための世代番号。 */
|
|
@@ -99,15 +112,23 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
99
112
|
/** 連鎖の起点。最後の 1 件ではなく最初の 1 件から窓を測る。 */
|
|
100
113
|
const authErrorRunStartedAtRef = useRef(0);
|
|
101
114
|
/**
|
|
102
|
-
* 401
|
|
103
|
-
*
|
|
115
|
+
* 直近に交換できたトークン。401 がどのトークンに対するものかの照合に使う。
|
|
116
|
+
* 拒否として受理した時点で null にする。SfaClient は 401 を返した
|
|
117
|
+
* リクエストごとに onAuthError を呼ぶので、同じトークンの並行分は以降
|
|
118
|
+
* 照合に失敗して落ちる。
|
|
104
119
|
*/
|
|
105
|
-
const
|
|
120
|
+
const currentTokenRef = useRef(null);
|
|
106
121
|
const hasEverAuthenticatedRef = useRef(false);
|
|
107
122
|
/** 同じ復帰の試行でコンソールに書いたかどうか。交換成功で false に戻す。 */
|
|
108
123
|
const hasLoggedExchangeFailureRef = useRef(false);
|
|
109
124
|
/** 打ち切り済み。以降は再要求も画面遷移もしない。 */
|
|
110
125
|
const isSettledRef = useRef(false);
|
|
126
|
+
/**
|
|
127
|
+
* 後片付け済み。SfaClient は解体後も生き続け、飛んでいたリクエストが 401 で
|
|
128
|
+
* 返ると onAuthError を呼ぶ。isSettledRef では代用できない(依存変更による
|
|
129
|
+
* 再実行でも立ってしまい、生きているセッションまで止まる)。
|
|
130
|
+
*/
|
|
131
|
+
const isTornDownRef = useRef(false);
|
|
111
132
|
const clearRefreshTimer = useCallback(() => {
|
|
112
133
|
if (refreshTimerRef.current != null) {
|
|
113
134
|
clearTimeout(refreshTimerRef.current);
|
|
@@ -120,6 +141,12 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
120
141
|
retryTimerRef.current = null;
|
|
121
142
|
}
|
|
122
143
|
}, []);
|
|
144
|
+
const clearCodeTimeout = useCallback(() => {
|
|
145
|
+
if (codeTimeoutRef.current != null) {
|
|
146
|
+
clearTimeout(codeTimeoutRef.current);
|
|
147
|
+
codeTimeoutRef.current = null;
|
|
148
|
+
}
|
|
149
|
+
}, []);
|
|
123
150
|
const isTokenUsable = useCallback(() => {
|
|
124
151
|
const session = sessionRef.current;
|
|
125
152
|
return session != null && session.expiresAt > Date.now();
|
|
@@ -127,13 +154,17 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
127
154
|
/** 打ち切って画面を差し替える。手元に使えるものが何も残っていないとき。 */
|
|
128
155
|
const settle = useCallback((next) => {
|
|
129
156
|
isSettledRef.current = true;
|
|
157
|
+
// 待ち時間のタイマーを止める。終了画面が書き換わるのを防いでいるのは
|
|
158
|
+
// ハンドラ側の waiting_for_code 判定のほうで、ここは残り 15 秒を
|
|
159
|
+
// 空回りさせないための後片付け
|
|
160
|
+
clearCodeTimeout();
|
|
130
161
|
// 進行中の交換を無効化する。これが無いと、打ち切った後に解決した交換が
|
|
131
162
|
// authenticated を書き戻し、二度と操作できない画面が残る。
|
|
132
163
|
exchangeGenerationRef.current += 1;
|
|
133
164
|
clearRefreshTimer();
|
|
134
165
|
clearRetryTimer();
|
|
135
166
|
setPhase(next);
|
|
136
|
-
}, [clearRefreshTimer, clearRetryTimer]);
|
|
167
|
+
}, [clearCodeTimeout, clearRefreshTimer, clearRetryTimer]);
|
|
137
168
|
/**
|
|
138
169
|
* 状態画面は子ツリーがまだ描画されていないときだけ出す。認証済みの表示中は
|
|
139
170
|
* phase を動かさず、ページの状態をそのまま残す。打ち切り後も動かさない。
|
|
@@ -154,9 +185,21 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
154
185
|
? current
|
|
155
186
|
: { step: 'exchanging' });
|
|
156
187
|
}, []);
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
// NOTE: 宛先は ref から読む。trustedOrigin を直接閉じ込めると、張り済みの
|
|
189
|
+
// タイマーが差し替え前の宛先へ送り続ける。あわせて sendCodeRequest の同一性が
|
|
190
|
+
// 安定し、これを使うコールバック(armCodeRetry / requestCode / scheduleRefresh)
|
|
191
|
+
// も張り直しの対象から外れる。
|
|
192
|
+
//
|
|
193
|
+
// NOTE: 代入はレンダー中ではなく effect で行う。並行レンダリングでは破棄される
|
|
194
|
+
// レンダーがあり、その値が残ると、一度も有効になっていない宛先へ送ることになる。
|
|
195
|
+
// useRef の初期値があるので、最初の effect までの窓が空になることはない。
|
|
196
|
+
const trustedOriginRef = useRef(trustedOrigin);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
trustedOriginRef.current = trustedOrigin;
|
|
159
199
|
}, [trustedOrigin]);
|
|
200
|
+
const sendCodeRequest = useCallback(() => {
|
|
201
|
+
sendToHost({ type: 'sfa-kit:code-request' }, trustedOriginRef.current);
|
|
202
|
+
}, []);
|
|
160
203
|
/** 打ち切り後にトークンが切れた時点で next に落とすためのタイマー。 */
|
|
161
204
|
const scheduleExpiryCutoff = useCallback((next) => {
|
|
162
205
|
clearRefreshTimer();
|
|
@@ -243,6 +286,10 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
243
286
|
}, delaySeconds * 1000);
|
|
244
287
|
}, [clearRefreshTimer, requestCode]);
|
|
245
288
|
const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
|
|
289
|
+
// コードが届いた時点でホストは手順に対応している。取りこぼした 1 通でも
|
|
290
|
+
// それは変わらないので、打ち切り済みかどうかの判定より先に止める。
|
|
291
|
+
// settle() 側と同じく、表示を守っているのはハンドラ側の判定のほう。
|
|
292
|
+
clearCodeTimeout();
|
|
246
293
|
if (isSettledRef.current)
|
|
247
294
|
return;
|
|
248
295
|
const startedAt = Date.now();
|
|
@@ -265,7 +312,7 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
265
312
|
if (exchangeGenerationRef.current !== generation)
|
|
266
313
|
return;
|
|
267
314
|
recoveryAttemptsRef.current = 0;
|
|
268
|
-
|
|
315
|
+
currentTokenRef.current = session.accessToken;
|
|
269
316
|
hasLoggedExchangeFailureRef.current = false;
|
|
270
317
|
hasEverAuthenticatedRef.current = true;
|
|
271
318
|
sessionRef.current = session;
|
|
@@ -317,6 +364,7 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
317
364
|
}
|
|
318
365
|
}), [
|
|
319
366
|
apiBase,
|
|
367
|
+
clearCodeTimeout,
|
|
320
368
|
scheduleRefresh,
|
|
321
369
|
armCodeRetry,
|
|
322
370
|
clearRetryTimer,
|
|
@@ -325,12 +373,37 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
325
373
|
showStatusIfNotMounted,
|
|
326
374
|
showExchangingIfNotMounted,
|
|
327
375
|
]);
|
|
328
|
-
const handleAuthError = useCallback(() => {
|
|
329
|
-
|
|
376
|
+
const handleAuthError = useCallback((rejectedToken) => {
|
|
377
|
+
// 解体後の 401 で復帰を始めない。もう無い画面のために使い捨てコードを
|
|
378
|
+
// ホストへ要求し続けることになる
|
|
379
|
+
if (isTornDownRef.current || isSettledRef.current)
|
|
330
380
|
return;
|
|
331
|
-
|
|
381
|
+
// 認証なしで飛んだリクエストの 401。埋め込みでは SfaProvider に
|
|
382
|
+
// トークンが渡ってから子ツリーを描き、exchangeCode も空のトークンを
|
|
383
|
+
// 弾くので、この経路は発生しない。発生するようになったら落とすのでは
|
|
384
|
+
// なく本物の拒否として扱うこと — さもないと、全部のリクエストが 401 に
|
|
385
|
+
// なる画面が認証済みの見た目のまま残る。
|
|
386
|
+
//
|
|
387
|
+
// NOTE: 下の照合に任せず明示的に落とす。任せると「undefined は string と
|
|
388
|
+
// 一致しない」という偶然が唯一の根拠になり、currentTokenRef の型を
|
|
389
|
+
// string | undefined に揃えるリファクタが、型エラーを出さないまま
|
|
390
|
+
// この分岐を反転させる。
|
|
391
|
+
if (rejectedToken == null)
|
|
332
392
|
return;
|
|
333
|
-
|
|
393
|
+
// 差し替え済みのトークンについての返事は捨てる。ホストへコードを
|
|
394
|
+
// 要求し直しても、そのリクエストはもう存在しないトークンのものなので
|
|
395
|
+
// 何も直らず、動いているセッションを巻き添えにするだけになる。
|
|
396
|
+
// 復帰中(currentTokenRef が null)の分もここで落ちる。
|
|
397
|
+
//
|
|
398
|
+
// 並行リクエストの重複もここで落ちる。最初の 1 件で currentTokenRef を
|
|
399
|
+
// null にするため、同じトークンの 2 件目以降は照合に失敗する。
|
|
400
|
+
//
|
|
401
|
+
// NOTE: 文字列の一致で見分けられるのは、Keystone が 1 回ごとに異なる
|
|
402
|
+
// トークンを出すから(jti に uuid4 が入る)。同じ値が再発行されると、
|
|
403
|
+
// 差し替え後に届いた遅れ分が新しいトークンの拒否と区別できなくなる。
|
|
404
|
+
if (rejectedToken !== currentTokenRef.current)
|
|
405
|
+
return;
|
|
406
|
+
currentTokenRef.current = null;
|
|
334
407
|
const now = Date.now();
|
|
335
408
|
// 窓は連鎖の起点から測る。直近の 1 件から測ると、正常に復帰している
|
|
336
409
|
// 25 秒間隔の 401 でも窓が滑り続けて打ち切りに達してしまう。
|
|
@@ -340,13 +413,18 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
340
413
|
authErrorRunStartedAtRef.current = now;
|
|
341
414
|
}
|
|
342
415
|
authErrorCyclesRef.current += 1;
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
416
|
+
// ここに達するのは、いま持っているトークンが実際に拒否された場合だけ。
|
|
417
|
+
// それでも上限を置くのは、Keystone が発行するそばから API に拒否される
|
|
418
|
+
// 状態を止めるため。交換自体は成功し続けるので、見張り側の試行回数は
|
|
419
|
+
// 毎回 0 に戻り、上限が無ければ永久に回り続ける。
|
|
347
420
|
if (authErrorCyclesRef.current > MAX_AUTH_ERROR_CYCLES) {
|
|
348
|
-
//
|
|
349
|
-
//
|
|
421
|
+
// 表示は保ち、コードの取り直しだけをやめて、失効した時点でセッション
|
|
422
|
+
// 切れとして扱う。
|
|
423
|
+
//
|
|
424
|
+
// NOTE: ここまで来たトークンは期限内だが拒否されている(照合を通った
|
|
425
|
+
// ので、遅れ着信ではない)。つまり残す画面ではリクエストが通らない。
|
|
426
|
+
// それでも畳まないのは、入力中の内容を失わせないため。どちらを採るかは
|
|
427
|
+
// 挙動の選択なので、変えるなら SFAJAI-202 の判断ごと見直すこと。
|
|
350
428
|
//
|
|
351
429
|
// 据え置きにする必要がある。ホストは要求を受けてから非同期にコードを
|
|
352
430
|
// 発行するので、打ち切った後に以前の要求への応答が届く。交換を続けて
|
|
@@ -367,6 +445,61 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
367
445
|
requestCode();
|
|
368
446
|
showStatusIfNotMounted({ step: 'waiting_for_code' });
|
|
369
447
|
}, [requestCode, clearRefreshTimer, stopRetrying, showStatusIfNotMounted]);
|
|
448
|
+
/** 直前に使っていた認証先。同一性の変化と、値の変化を見分けるために持つ。 */
|
|
449
|
+
const previousApiBaseRef = useRef(apiBase);
|
|
450
|
+
/**
|
|
451
|
+
* @description 認証先が変わっていれば、旧 API を前提にした状態をすべて捨てて
|
|
452
|
+
* やり直す。変わっていなければ何もしない。
|
|
453
|
+
*
|
|
454
|
+
* 判定を内側に持つのは、依存の同一性が変わっただけの張り直しで走らせない
|
|
455
|
+
* ため。やり直す理由は認証先が変わったことであって、effect が走り直した
|
|
456
|
+
* ことではない。
|
|
457
|
+
*
|
|
458
|
+
* 交換を始めた時点の apiBase が、その結果が通用する認証先を決める。返ってくる
|
|
459
|
+
* トークンは旧 API が発行したものなので採用できない。採用すると SfaProvider が
|
|
460
|
+
* 新しい API へ旧トークンを送り続けることになる。
|
|
461
|
+
*
|
|
462
|
+
* NOTE: 世代を進めるだけでは足りない。世代がずれると handleCodeReceived の
|
|
463
|
+
* finally が exchangingRef を戻さず、張り直されたメッセージ effect が送る ready
|
|
464
|
+
* への返事まで「交換中」と見なされて STALE_EXCHANGE_SECONDS の間無視される。
|
|
465
|
+
*
|
|
466
|
+
* NOTE: isSettledRef は戻さない。打ち切り済みの画面は認証先を変えても復帰しない。
|
|
467
|
+
* 復帰させるなら settle() が消したタイマー群も張り直す必要があり、終了画面を
|
|
468
|
+
* 出したまま裏で再試行が走る状態を招く。再読み込みを復帰手段とする。
|
|
469
|
+
*/
|
|
470
|
+
const restartIfAuthTargetChanged = useCallback((nextApiBase) => {
|
|
471
|
+
if (previousApiBaseRef.current === nextApiBase)
|
|
472
|
+
return;
|
|
473
|
+
previousApiBaseRef.current = nextApiBase;
|
|
474
|
+
exchangeGenerationRef.current += 1;
|
|
475
|
+
exchangingRef.current = false;
|
|
476
|
+
// 旧 API のトークンは新しい認証先では通らない。保持判定から外す
|
|
477
|
+
sessionRef.current = null;
|
|
478
|
+
// NOTE: これ以降、旧トークンについての 401 は照合に失敗して捨てられる。
|
|
479
|
+
// 旧トークンで復帰を始めても新しい認証先では通らないので意味がない。
|
|
480
|
+
// 復帰はメッセージ effect が送り直す ready と、下で張る見張りが担う。
|
|
481
|
+
currentTokenRef.current = null;
|
|
482
|
+
// 認証先ごとに試行回数を数え直す。引き継ぐと上限の手前から始まることになり、
|
|
483
|
+
// armCodeRetry は上限判定を送信より先に行うため、新しい認証先へ
|
|
484
|
+
// code-request を 1 通も送らないまま打ち切られる。
|
|
485
|
+
//
|
|
486
|
+
// NOTE: 上限に達するには同じ認証先が 1 分ほど続く必要がある。それより短い
|
|
487
|
+
// 間隔で認証先が変わり続ける限り打ち切りには達しない。apiBase は
|
|
488
|
+
// resolveApiBase の純粋な結果で、ページの寿命の間は変わらない前提。
|
|
489
|
+
recoveryAttemptsRef.current = 0;
|
|
490
|
+
authErrorCyclesRef.current = 0;
|
|
491
|
+
authErrorRunStartedAtRef.current = 0;
|
|
492
|
+
// ready はメッセージ effect が送り直す。ここでは応答が無かったときの
|
|
493
|
+
// 見張りだけ張る
|
|
494
|
+
armCodeRetryRef.current();
|
|
495
|
+
// NOTE: 先回り更新のタイマーはここで消さない。refreshTimerRef は打ち切り後の
|
|
496
|
+
// 失効タイマー(scheduleExpiryCutoff)も兼ねており、消すと「セッション期限切れ」
|
|
497
|
+
// へ落ちなくなる。旧セッションの期限で残っても、交換が成功した時点で
|
|
498
|
+
// scheduleRefresh が張り替えるため実害はない。
|
|
499
|
+
}, []);
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
restartIfAuthTargetChanged(apiBase);
|
|
502
|
+
}, [apiBase, restartIfAuthTargetChanged]);
|
|
370
503
|
useEffect(() => {
|
|
371
504
|
const handleMessage = (event) => {
|
|
372
505
|
var _a;
|
|
@@ -382,7 +515,16 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
382
515
|
// If the host never sends a code (e.g. mintSfaKitExchangeCode is not
|
|
383
516
|
// implemented or the host page doesn't support the embed protocol),
|
|
384
517
|
// show an error instead of hanging on "Connecting..." forever.
|
|
385
|
-
|
|
518
|
+
//
|
|
519
|
+
// NOTE: settle では閉じない。ホストが遅いだけなら、期限を過ぎて届いた
|
|
520
|
+
// コードでそのまま復帰できる。打ち切ってしまうと、有効なコードが数秒後に
|
|
521
|
+
// 来ているのに再読み込みするまで死んだままになる。
|
|
522
|
+
//
|
|
523
|
+
// 待ち状態のときだけ出す。この判定が effect の張り直しも兼ねる — 既に
|
|
524
|
+
// 認証済み・交換中・別の理由で打ち切り済みなら、どれも
|
|
525
|
+
// waiting_for_code ではないので、より正確な表示を上書きしない。
|
|
526
|
+
codeTimeoutRef.current = setTimeout(() => {
|
|
527
|
+
codeTimeoutRef.current = null;
|
|
386
528
|
setPhase(current => {
|
|
387
529
|
if (current.step !== 'waiting_for_code')
|
|
388
530
|
return current;
|
|
@@ -393,12 +535,40 @@ export function useEmbedSession({ apiBase, trustedOrigin, }) {
|
|
|
393
535
|
};
|
|
394
536
|
});
|
|
395
537
|
}, CODE_TIMEOUT_SECONDS * 1000);
|
|
538
|
+
// NOTE: 消すのはこの effect が張ったコード待ちタイマーだけ。更新・再試行の
|
|
539
|
+
// タイマーはこの effect の持ち物ではなく、フックが載っている間ずっと生きる。
|
|
540
|
+
// ここで消すと、認証済みのまま apiBase や trustedOrigin が変わっただけで
|
|
541
|
+
// 先回り更新が失われ、誰も張り直さないままトークンが期限切れになる。
|
|
396
542
|
return () => {
|
|
397
543
|
window.removeEventListener('message', handleMessage);
|
|
398
|
-
|
|
399
|
-
clearRefreshTimer();
|
|
400
|
-
clearRetryTimer();
|
|
544
|
+
clearCodeTimeout();
|
|
401
545
|
};
|
|
402
|
-
}, [trustedOrigin, handleCodeReceived,
|
|
546
|
+
}, [trustedOrigin, handleCodeReceived, clearCodeTimeout]);
|
|
547
|
+
// NOTE: 解体は依存を持たない effect に分ける。上のメッセージ effect の後片付けは
|
|
548
|
+
// 依存の同一性が変わるたびに走るので、そこで世代を進めると、認証先は同じままの
|
|
549
|
+
// 張り直しでも進行中の交換が捨てられる。認証先が変わった場合の破棄は専用の
|
|
550
|
+
// effect(authTargetRef)が担う。ここは本当にアンマウントするときだけ走る。
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
// ref は再マウントを越えて残る。StrictMode や Fast Refresh は
|
|
553
|
+
// マウント → 解体 → 再マウントと走るので、ここで戻さないと
|
|
554
|
+
// handleAuthError が最初の行で return し続け、401 から復帰できなくなる。
|
|
555
|
+
isTornDownRef.current = false;
|
|
556
|
+
return () => {
|
|
557
|
+
isTornDownRef.current = true;
|
|
558
|
+
// 解体後に解決した交換で、この下で消すタイマーを張り直させない。
|
|
559
|
+
// 世代を進めると handleCodeReceived が結果を捨てる。進行中の交換は
|
|
560
|
+
// どれも現行世代のものではなくなるため、居座り判定も解除する。
|
|
561
|
+
exchangeGenerationRef.current += 1;
|
|
562
|
+
exchangingRef.current = false;
|
|
563
|
+
// NOTE: clearRefreshTimer / clearRetryTimer は経由せず直接消す。経由すると
|
|
564
|
+
// 依存配列が空でなくなり、「アンマウント時だけ」がそれらの同一性の安定に
|
|
565
|
+
// 依存する。両者は settle → armCodeRetry → scheduleRefresh →
|
|
566
|
+
// handleCodeReceived まで伝播しているため、影響範囲が広い。
|
|
567
|
+
if (refreshTimerRef.current != null)
|
|
568
|
+
clearTimeout(refreshTimerRef.current);
|
|
569
|
+
if (retryTimerRef.current != null)
|
|
570
|
+
clearTimeout(retryTimerRef.current);
|
|
571
|
+
};
|
|
572
|
+
}, []);
|
|
403
573
|
return { phase, handleAuthError };
|
|
404
574
|
}
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import type { BundleManifest } from '../signing/types.js';
|
|
1
|
+
import type { BundleManifest, ContentHashManifest } from '../signing/types.js';
|
|
2
2
|
export interface BuildStaticBundleOptions {
|
|
3
3
|
/** Entry module that mounts the page, e.g. `entry.tsx`. */
|
|
4
4
|
readonly entry: string;
|
|
5
5
|
/** Directory the bundle is written to. Created if absent. */
|
|
6
6
|
readonly outDir: string;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* @deprecated Omit it. The bundle is then signed over `content_hash` alone.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* `"{studio_version}:{content_hash}"
|
|
12
|
-
*
|
|
10
|
+
* When given, the bundle is signed in the legacy format, over
|
|
11
|
+
* `"{studio_version}:{content_hash}"`, and the value is written to the
|
|
12
|
+
* manifest. Kept so that builds which still pass it keep producing a manifest
|
|
13
|
+
* with `studio_version`. Keystone accepts both formats.
|
|
13
14
|
*/
|
|
14
|
-
readonly studioVersion
|
|
15
|
+
readonly studioVersion?: string;
|
|
15
16
|
/** Keystone API base, e.g. `https://keystone-api-staging.chikyu.mobi`. */
|
|
16
17
|
readonly apiBase: string;
|
|
17
18
|
/** The same Bearer token the rest of the Keystone API is called with. */
|
|
@@ -19,17 +20,32 @@ export interface BuildStaticBundleOptions {
|
|
|
19
20
|
/** Title of the generated `index.html`. */
|
|
20
21
|
readonly title?: string;
|
|
21
22
|
/**
|
|
22
|
-
* Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores
|
|
23
|
-
*
|
|
23
|
+
* Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores them.
|
|
24
|
+
* The fields this builder writes (`content_hash`, `signature`,
|
|
25
|
+
* `sfa_kit_version` and `studio_version`) cannot be set here.
|
|
24
26
|
*/
|
|
25
27
|
readonly manifestExtras?: Readonly<Record<string, unknown>>;
|
|
26
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* @deprecated Omit `studioVersion`. Given one, the bundle is signed in the
|
|
31
|
+
* legacy format, over `"{studio_version}:{content_hash}"`, and the manifest
|
|
32
|
+
* carries `studio_version` beside `sfa_kit_version`. See the next overload for
|
|
33
|
+
* everything else this function does.
|
|
34
|
+
*
|
|
35
|
+
* NOTE: the order of the three overloads matters, since a call resolves to the
|
|
36
|
+
* first that matches: a `studioVersion` string here, none next, and one known
|
|
37
|
+
* only at run time (`string | undefined`) last.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildStaticBundle(options: BuildStaticBundleOptions & {
|
|
40
|
+
readonly studioVersion: string;
|
|
41
|
+
}): Promise<BundleManifest & Pick<ContentHashManifest, 'sfa_kit_version'>>;
|
|
27
42
|
/**
|
|
28
43
|
* @description Builds the framework-free static bundle a page is uploaded as
|
|
29
|
-
* to become a custom tab, and signs it.
|
|
44
|
+
* to become a custom tab, and signs it over `content_hash` alone.
|
|
30
45
|
*
|
|
31
46
|
* Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
|
|
32
|
-
* `outDir`. The
|
|
47
|
+
* `outDir`. The manifest records this package's version as `sfa_kit_version`.
|
|
48
|
+
* The caller zips that directory's contents — the files have to sit
|
|
33
49
|
* at the zip root. Zipping is left out because Node ships no zip writer, and a
|
|
34
50
|
* dependency for it would reach a package whose runtime needs only `tslib`.
|
|
35
51
|
*
|
|
@@ -42,4 +58,14 @@ export interface BuildStaticBundleOptions {
|
|
|
42
58
|
*
|
|
43
59
|
* @returns The manifest that was written.
|
|
44
60
|
*/
|
|
45
|
-
export declare function buildStaticBundle(
|
|
61
|
+
export declare function buildStaticBundle(options: BuildStaticBundleOptions & {
|
|
62
|
+
readonly studioVersion?: undefined;
|
|
63
|
+
}): Promise<ContentHashManifest>;
|
|
64
|
+
/**
|
|
65
|
+
* For a `studioVersion` known only at run time (`string | undefined`), such as
|
|
66
|
+
* a forwarded command-line flag. The result is the legacy manifest when one is
|
|
67
|
+
* given and the current format otherwise, so `studio_version` reads as
|
|
68
|
+
* `string | undefined`. See the previous overload for everything else this
|
|
69
|
+
* function does.
|
|
70
|
+
*/
|
|
71
|
+
export declare function buildStaticBundle(options: BuildStaticBundleOptions): Promise<ContentHashManifest | (BundleManifest & Pick<ContentHashManifest, 'sfa_kit_version'>)>;
|
|
@@ -3,8 +3,9 @@ import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises
|
|
|
3
3
|
import { join, posix, relative } from 'node:path';
|
|
4
4
|
import { computeContentHash } from '../signing/compute-content-hash.js';
|
|
5
5
|
import { readBundleFiles } from '../signing/read-bundle-files.js';
|
|
6
|
+
import { SFA_KIT_VERSION } from '../version.js';
|
|
6
7
|
import { loadEsbuild } from './esbuild-loader.js';
|
|
7
|
-
import { requestSignature } from './request-signature.js';
|
|
8
|
+
import { isHexDigest, requestSignature } from './request-signature.js';
|
|
8
9
|
/** Where the bundled script lands inside the output directory. */
|
|
9
10
|
const BUNDLE_PATH = 'assets/bundle.js';
|
|
10
11
|
/** esbuild writes this beside the script when the entry imports any CSS. */
|
|
@@ -31,29 +32,23 @@ const UNEXPECTED_SHOWN = 5;
|
|
|
31
32
|
*/
|
|
32
33
|
const JUNK_FILES = new Set(['.DS_Store', 'Thumbs.db']);
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
* to become a custom tab, and signs it.
|
|
35
|
+
* Manifest keys this builder writes. A caller's `manifestExtras` never set them.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* dependency for it would reach a package whose runtime needs only `tslib`.
|
|
41
|
-
*
|
|
42
|
-
* Signing is not optional, and a failure removes the output: an unsigned bundle
|
|
43
|
-
* uploads successfully while Keystone's verifier runs in `disabled` mode and
|
|
44
|
-
* only starts failing at `enforced`, so a half-built directory left behind is
|
|
45
|
-
* something a caller can still zip and upload by mistake.
|
|
46
|
-
*
|
|
47
|
-
* **Node.js only**, and needs `esbuild` installed alongside the kit.
|
|
48
|
-
*
|
|
49
|
-
* @returns The manifest that was written.
|
|
37
|
+
* NOTE: `studio_version` is listed even for builds that leave it out. Keystone
|
|
38
|
+
* checks a manifest carrying one in the legacy format, so an extra holding it
|
|
39
|
+
* would turn a correctly signed bundle into a 422.
|
|
50
40
|
*/
|
|
41
|
+
const OWN_MANIFEST_KEYS = new Set([
|
|
42
|
+
'studio_version',
|
|
43
|
+
'sfa_kit_version',
|
|
44
|
+
'content_hash',
|
|
45
|
+
'signature',
|
|
46
|
+
]);
|
|
51
47
|
export function buildStaticBundle(_a) {
|
|
52
48
|
return __awaiter(this, arguments, void 0, function* ({ entry, outDir, studioVersion, apiBase, accessToken, title = 'SFA/CRM', manifestExtras = {}, }) {
|
|
53
49
|
for (const [name, value] of [
|
|
54
50
|
['entry', entry],
|
|
55
51
|
['outDir', outDir],
|
|
56
|
-
['studioVersion', studioVersion],
|
|
57
52
|
['apiBase', apiBase],
|
|
58
53
|
['accessToken', accessToken],
|
|
59
54
|
]) {
|
|
@@ -66,6 +61,11 @@ export function buildStaticBundle(_a) {
|
|
|
66
61
|
throw new Error(`${name} is required and must not be empty`);
|
|
67
62
|
}
|
|
68
63
|
}
|
|
64
|
+
// NOTE: refused rather than read as "omitted". The overloads promise a
|
|
65
|
+
// manifest carrying studio_version whenever a studioVersion is passed.
|
|
66
|
+
if (studioVersion != null && studioVersion.trim() === '') {
|
|
67
|
+
throw new Error('studioVersion must not be empty. Omit it to sign content_hash alone.');
|
|
68
|
+
}
|
|
69
69
|
// Serialised now so a value that cannot be JSON — cyclic, a BigInt — fails
|
|
70
70
|
// before anything is written, rather than after Keystone has signed.
|
|
71
71
|
assertSerialisable(manifestExtras);
|
|
@@ -90,20 +90,27 @@ export function buildStaticBundle(_a) {
|
|
|
90
90
|
// manifest.json is written last: the hash covers every other file, so the
|
|
91
91
|
// directory has to be complete before it is computed.
|
|
92
92
|
const contentHash = computeContentHash(yield readBundleFiles(outDir));
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
93
|
+
const signature = yield requestSignature({
|
|
94
|
+
apiBase,
|
|
95
|
+
accessToken,
|
|
96
|
+
studioVersion,
|
|
97
|
+
contentHash,
|
|
98
|
+
});
|
|
99
|
+
// NOTE: one object per format, not a conditional spread. The current
|
|
100
|
+
// format must not carry `studio_version` at all (see ContentHashManifest).
|
|
101
|
+
const manifest = studioVersion == null
|
|
102
|
+
? {
|
|
103
|
+
sfa_kit_version: SFA_KIT_VERSION,
|
|
104
|
+
content_hash: contentHash,
|
|
105
|
+
signature,
|
|
106
|
+
}
|
|
107
|
+
: {
|
|
108
|
+
studio_version: studioVersion,
|
|
109
|
+
sfa_kit_version: SFA_KIT_VERSION,
|
|
110
|
+
content_hash: contentHash,
|
|
111
|
+
signature,
|
|
112
|
+
};
|
|
113
|
+
yield writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(Object.assign(Object.assign({}, withoutOwnKeys(manifestExtras)), manifest), null, 2)}\n`, 'utf-8');
|
|
107
114
|
return manifest;
|
|
108
115
|
}
|
|
109
116
|
catch (thrown) {
|
|
@@ -118,6 +125,16 @@ export function buildStaticBundle(_a) {
|
|
|
118
125
|
}
|
|
119
126
|
});
|
|
120
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Drops the keys this builder writes from a caller's extras.
|
|
130
|
+
*
|
|
131
|
+
* NOTE: spreading the extras first is not enough. A build without a
|
|
132
|
+
* studioVersion writes no `studio_version`, so one in the extras would survive
|
|
133
|
+
* the spread; {@link OWN_MANIFEST_KEYS} says why it must not reach the manifest.
|
|
134
|
+
*/
|
|
135
|
+
function withoutOwnKeys(extras) {
|
|
136
|
+
return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_MANIFEST_KEYS.has(key)));
|
|
137
|
+
}
|
|
121
138
|
/** Fails now on a value `JSON.stringify` would reject after the bundle is signed. */
|
|
122
139
|
function assertSerialisable(extras) {
|
|
123
140
|
try {
|
|
@@ -140,8 +157,8 @@ function assertSerialisable(extras) {
|
|
|
140
157
|
*
|
|
141
158
|
* NOTE: matching those names is not enough to claim the directory. `index.html`
|
|
142
159
|
* beside `assets/bundle.js` is also what a hand-written static site looks like,
|
|
143
|
-
* and deleting one would be unrecoverable.
|
|
144
|
-
*
|
|
160
|
+
* and deleting one would be unrecoverable. The manifest is the proof; see
|
|
161
|
+
* {@link hasOwnManifest} for what counts.
|
|
145
162
|
*/
|
|
146
163
|
function prepareOutDir(outDir) {
|
|
147
164
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -205,7 +222,24 @@ function prepareOutDir(outDir) {
|
|
|
205
222
|
yield removeOwnOutput(outDir);
|
|
206
223
|
});
|
|
207
224
|
}
|
|
208
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* @description Whether the directory holds a previous build this builder may
|
|
227
|
+
* overwrite.
|
|
228
|
+
*
|
|
229
|
+
* NOTE: `content_hash` and `signature` alone are not proof. Any signed bundle
|
|
230
|
+
* carries them, and accepting one would let a rebuild delete another bundle's
|
|
231
|
+
* files. Each format also needs its version field: `sfa_kit_version` in the
|
|
232
|
+
* current format, `studio_version` in the legacy one (the condition earlier
|
|
233
|
+
* releases used).
|
|
234
|
+
*
|
|
235
|
+
* Neither field proves this builder wrote the manifest. GUIDE.md's hand-built
|
|
236
|
+
* flow writes `sfa_kit_version` too, and `createManifest` or a hand-run
|
|
237
|
+
* equivalent writes `studio_version`. Both identify a bundle built through
|
|
238
|
+
* sfa-kit, not this builder alone. Nothing in a manifest narrows it further,
|
|
239
|
+
* and refusing those would block rebuilding over existing output directories.
|
|
240
|
+
* What is lost is bounded: only the paths in `OWN_OUTPUT` are removed, and any
|
|
241
|
+
* other file stops the build first.
|
|
242
|
+
*/
|
|
209
243
|
function hasOwnManifest(outDir) {
|
|
210
244
|
return __awaiter(this, void 0, void 0, function* () {
|
|
211
245
|
const raw = yield readFile(join(outDir, 'manifest.json'), 'utf-8').catch(() => null);
|
|
@@ -213,13 +247,19 @@ function hasOwnManifest(outDir) {
|
|
|
213
247
|
return false;
|
|
214
248
|
try {
|
|
215
249
|
const parsed = JSON.parse(raw);
|
|
216
|
-
|
|
250
|
+
const hasHexDigests = isHexDigest(parsed['content_hash']) && isHexDigest(parsed['signature']);
|
|
251
|
+
const hasVersionField = isNonEmptyString(parsed['sfa_kit_version']) ||
|
|
252
|
+
isNonEmptyString(parsed['studio_version']);
|
|
253
|
+
return hasHexDigests && hasVersionField;
|
|
217
254
|
}
|
|
218
255
|
catch (_a) {
|
|
219
256
|
return false;
|
|
220
257
|
}
|
|
221
258
|
});
|
|
222
259
|
}
|
|
260
|
+
function isNonEmptyString(value) {
|
|
261
|
+
return typeof value === 'string' && value !== '';
|
|
262
|
+
}
|
|
223
263
|
/**
|
|
224
264
|
* @description Deletes the files this builder writes, and nothing else.
|
|
225
265
|
*
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `value` has the shape Keystone gives `content_hash` and `signature`.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: shared with the builder's previous-build check, so the shape the
|
|
5
|
+
* signer accepts and the shape a rebuild recognises cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isHexDigest(value: unknown): value is string;
|
|
1
8
|
/**
|
|
2
9
|
* @description Asks Keystone to sign a bundle, returning the signature.
|
|
3
10
|
*
|
|
@@ -6,6 +13,11 @@
|
|
|
6
13
|
* per project while the key must be identical across all of them (SFAJAI-186).
|
|
7
14
|
* The caller computes `contentHash` locally and Keystone signs it.
|
|
8
15
|
*
|
|
16
|
+
* Without `studioVersion`, Keystone signs `contentHash` alone. With it, Keystone
|
|
17
|
+
* returns the legacy signature over `"{studio_version}:{content_hash}"`, and the
|
|
18
|
+
* caller must write the same `studio_version` into the manifest for it to
|
|
19
|
+
* verify.
|
|
20
|
+
*
|
|
9
21
|
* NOTE: every failure throws. An unsigned bundle uploads successfully while
|
|
10
22
|
* Keystone's verifier runs in `disabled` mode, so returning "no signature" here
|
|
11
23
|
* would produce an artifact that looks fine and starts being rejected the day
|
|
@@ -17,6 +29,6 @@
|
|
|
17
29
|
export declare function requestSignature({ apiBase, accessToken, studioVersion, contentHash, }: {
|
|
18
30
|
readonly apiBase: string;
|
|
19
31
|
readonly accessToken: string;
|
|
20
|
-
readonly studioVersion
|
|
32
|
+
readonly studioVersion?: string;
|
|
21
33
|
readonly contentHash: string;
|
|
22
34
|
}): Promise<string>;
|