@geniee-inc/sfa-kit 1.16.0 → 1.18.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.
Files changed (39) hide show
  1. package/GUIDE.md +92 -2
  2. package/package.json +19 -2
  3. package/src/bin/sfa-kit-bundle.d.ts +13 -0
  4. package/src/bin/sfa-kit-bundle.js +142 -0
  5. package/src/bundle.d.ts +26 -0
  6. package/src/bundle.js +1 -0
  7. package/src/index.d.ts +1 -1
  8. package/src/lib/auth/resolve-api-base.d.ts +6 -4
  9. package/src/lib/auth/resolve-api-base.js +24 -5
  10. package/src/lib/auth/useEmbedSession.d.ts +2 -1
  11. package/src/lib/auth/useEmbedSession.js +196 -26
  12. package/src/lib/bundle/build-static-bundle.d.ts +45 -0
  13. package/src/lib/bundle/build-static-bundle.js +263 -0
  14. package/src/lib/bundle/esbuild-loader.d.ts +25 -0
  15. package/src/lib/bundle/esbuild-loader.js +27 -0
  16. package/src/lib/bundle/index.d.ts +2 -0
  17. package/src/lib/bundle/index.js +1 -0
  18. package/src/lib/bundle/request-signature.d.ts +22 -0
  19. package/src/lib/bundle/request-signature.js +81 -0
  20. package/src/lib/provider/SfaClient.d.ts +13 -0
  21. package/src/lib/provider/SfaClient.js +25 -8
  22. package/src/lib/provider/SfaProvider.d.ts +2 -2
  23. package/src/lib/provider/SfaProvider.js +1 -1
  24. package/src/lib/provider/types.d.ts +12 -1
  25. package/src/lib/signing/compute-content-hash.d.ts +1 -1
  26. package/src/lib/signing/compute-signature.d.ts +17 -0
  27. package/src/lib/signing/compute-signature.js +17 -0
  28. package/src/lib/signing/create-manifest-from-directory.d.ts +13 -3
  29. package/src/lib/signing/create-manifest-from-directory.js +15 -28
  30. package/src/lib/signing/create-manifest.d.ts +7 -1
  31. package/src/lib/signing/create-manifest.js +8 -2
  32. package/src/lib/signing/index.d.ts +6 -5
  33. package/src/lib/signing/index.js +5 -4
  34. package/src/lib/signing/read-bundle-files.d.ts +19 -0
  35. package/src/lib/signing/read-bundle-files.js +41 -0
  36. package/src/lib/version.d.ts +1 -1
  37. package/src/lib/version.js +1 -1
  38. package/src/signing.d.ts +9 -4
  39. package/src/signing.js +1 -1
@@ -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 401 landing within this window of the previous one belongs to the same
25
- * recovery run. Without it, a token Keystone happily mints but the API keeps
26
- * rejecting loops forever, because every exchange succeeds and resets counters.
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
- /** 401-triggered recovery cycles allowed inside AUTH_ERROR_WINDOW_SECONDS. */
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 を受けてコードを要求済みかどうか。SfaClient は 401 を返した
103
- * リクエストごとに onAuthError を呼ぶため、並行リクエストの分をまとめる。
115
+ * 直近に交換できたトークン。401 がどのトークンに対するものかの照合に使う。
116
+ * 拒否として受理した時点で null にする。SfaClient は 401 を返した
117
+ * リクエストごとに onAuthError を呼ぶので、同じトークンの並行分は以降
118
+ * 照合に失敗して落ちる。
104
119
  */
105
- const isAuthErrorPendingRef = useRef(false);
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
- const sendCodeRequest = useCallback(() => {
158
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
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
- isAuthErrorPendingRef.current = false;
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
- if (isSettledRef.current)
376
+ const handleAuthError = useCallback((rejectedToken) => {
377
+ // 解体後の 401 で復帰を始めない。もう無い画面のために使い捨てコードを
378
+ // ホストへ要求し続けることになる
379
+ if (isTornDownRef.current || isSettledRef.current)
330
380
  return;
331
- if (isAuthErrorPendingRef.current)
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
- isAuthErrorPendingRef.current = true;
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
- // ここに達した時点で交換自体は通り続けている。その 401 は差し替え前の
344
- // トークンで投げられたリクエストの遅れ着信であることが多く、手元では
345
- // 見分けられない(onAuthError は拒否されたトークンを渡さない)。
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
- const codeTimeout = setTimeout(() => {
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
- clearTimeout(codeTimeout);
399
- clearRefreshTimer();
400
- clearRetryTimer();
544
+ clearCodeTimeout();
401
545
  };
402
- }, [trustedOrigin, handleCodeReceived, clearRefreshTimer, clearRetryTimer]);
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
  }
@@ -0,0 +1,45 @@
1
+ import type { BundleManifest } from '../signing/types.js';
2
+ export interface BuildStaticBundleOptions {
3
+ /** Entry module that mounts the page, e.g. `entry.tsx`. */
4
+ readonly entry: string;
5
+ /** Directory the bundle is written to. Created if absent. */
6
+ readonly outDir: string;
7
+ /**
8
+ * The Studio build version this bundle targets — not the sfa-kit npm version.
9
+ *
10
+ * NOTE: required on purpose. The signature covers
11
+ * `"{studio_version}:{content_hash}"` and Keystone reads the field back
12
+ * separately, so a default would sign the wrong version perfectly well.
13
+ */
14
+ readonly studioVersion: string;
15
+ /** Keystone API base, e.g. `https://keystone-api-staging.chikyu.mobi`. */
16
+ readonly apiBase: string;
17
+ /** The same Bearer token the rest of the Keystone API is called with. */
18
+ readonly accessToken: string;
19
+ /** Title of the generated `index.html`. */
20
+ readonly title?: string;
21
+ /**
22
+ * Extra manifest fields, e.g. `name` or `displayName`. Keystone ignores
23
+ * everything but the three signed fields, which cannot be overridden here.
24
+ */
25
+ readonly manifestExtras?: Readonly<Record<string, unknown>>;
26
+ }
27
+ /**
28
+ * @description Builds the framework-free static bundle a page is uploaded as
29
+ * to become a custom tab, and signs it.
30
+ *
31
+ * Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
32
+ * `outDir`. The caller zips that directory's contents — the files have to sit
33
+ * at the zip root. Zipping is left out because Node ships no zip writer, and a
34
+ * dependency for it would reach a package whose runtime needs only `tslib`.
35
+ *
36
+ * Signing is not optional, and a failure removes the output: an unsigned bundle
37
+ * uploads successfully while Keystone's verifier runs in `disabled` mode and
38
+ * only starts failing at `enforced`, so a half-built directory left behind is
39
+ * something a caller can still zip and upload by mistake.
40
+ *
41
+ * **Node.js only**, and needs `esbuild` installed alongside the kit.
42
+ *
43
+ * @returns The manifest that was written.
44
+ */
45
+ export declare function buildStaticBundle({ entry, outDir, studioVersion, apiBase, accessToken, title, manifestExtras, }: BuildStaticBundleOptions): Promise<BundleManifest>;
@@ -0,0 +1,263 @@
1
+ import { __awaiter } from "tslib";
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises';
3
+ import { join, posix, relative } from 'node:path';
4
+ import { computeContentHash } from '../signing/compute-content-hash.js';
5
+ import { readBundleFiles } from '../signing/read-bundle-files.js';
6
+ import { loadEsbuild } from './esbuild-loader.js';
7
+ import { requestSignature } from './request-signature.js';
8
+ /** Where the bundled script lands inside the output directory. */
9
+ const BUNDLE_PATH = 'assets/bundle.js';
10
+ /** esbuild writes this beside the script when the entry imports any CSS. */
11
+ const STYLES_PATH = 'assets/bundle.css';
12
+ /**
13
+ * Every path this builder writes, relative to `outDir` with forward slashes.
14
+ *
15
+ * NOTE: full paths, not top-level names. Matching on names would accept any
16
+ * directory shaped like a static site — an `index.html` beside an `assets/`
17
+ * folder — and clear it.
18
+ */
19
+ const OWN_OUTPUT = new Set([
20
+ 'index.html',
21
+ 'manifest.json',
22
+ BUNDLE_PATH,
23
+ STYLES_PATH,
24
+ ]);
25
+ /** How many unexpected paths an error names before it stops listing them. */
26
+ const UNEXPECTED_SHOWN = 5;
27
+ /**
28
+ * Removed rather than refused. Opening the output directory in Finder to zip
29
+ * it — which GUIDE.md tells the reader to do — leaves a `.DS_Store` behind,
30
+ * and refusing it would wedge the build → zip → rebuild loop on a hidden file.
31
+ */
32
+ const JUNK_FILES = new Set(['.DS_Store', 'Thumbs.db']);
33
+ /**
34
+ * @description Builds the framework-free static bundle a page is uploaded as
35
+ * to become a custom tab, and signs it.
36
+ *
37
+ * Produces `index.html`, `assets/bundle.js` and a signed `manifest.json` in
38
+ * `outDir`. The caller zips that directory's contents — the files have to sit
39
+ * at the zip root. Zipping is left out because Node ships no zip writer, and a
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.
50
+ */
51
+ export function buildStaticBundle(_a) {
52
+ return __awaiter(this, arguments, void 0, function* ({ entry, outDir, studioVersion, apiBase, accessToken, title = 'SFA/CRM', manifestExtras = {}, }) {
53
+ for (const [name, value] of [
54
+ ['entry', entry],
55
+ ['outDir', outDir],
56
+ ['studioVersion', studioVersion],
57
+ ['apiBase', apiBase],
58
+ ['accessToken', accessToken],
59
+ ]) {
60
+ // NOTE: checked before esbuild runs. An empty apiBase otherwise surfaces as
61
+ // "Failed to parse URL from /v1/pages/sign" after a full build.
62
+ // NOTE: `== null` as well as empty. The subpath is published, and a JS
63
+ // caller omitting a field would otherwise pay for a full build to reach
64
+ // `Authorization: Bearer undefined`.
65
+ if (value == null || value === '') {
66
+ throw new Error(`${name} is required and must not be empty`);
67
+ }
68
+ }
69
+ // Serialised now so a value that cannot be JSON — cyclic, a BigInt — fails
70
+ // before anything is written, rather than after Keystone has signed.
71
+ assertSerialisable(manifestExtras);
72
+ const esbuild = yield loadEsbuild();
73
+ yield prepareOutDir(outDir);
74
+ yield mkdir(join(outDir, 'assets'), { recursive: true });
75
+ try {
76
+ yield esbuild.build({
77
+ entryPoints: [entry],
78
+ outfile: join(outDir, BUNDLE_PATH),
79
+ bundle: true,
80
+ minify: true,
81
+ format: 'esm',
82
+ jsx: 'automatic',
83
+ target: 'es2020',
84
+ });
85
+ // entry が CSS を import していると esbuild が bundle.css も書き出す。
86
+ // index.html から参照しないと、署名も検証も通ったうえで無スタイルの画面が
87
+ // 出る — どこにも失敗が現れない
88
+ const hasStyles = yield stat(join(outDir, STYLES_PATH)).then(() => true, () => false);
89
+ yield writeFile(join(outDir, 'index.html'), renderIndexHtml(title, hasStyles), 'utf-8');
90
+ // manifest.json is written last: the hash covers every other file, so the
91
+ // directory has to be complete before it is computed.
92
+ const contentHash = computeContentHash(yield readBundleFiles(outDir));
93
+ const manifest = {
94
+ studio_version: studioVersion,
95
+ content_hash: contentHash,
96
+ signature: yield requestSignature({
97
+ apiBase,
98
+ accessToken,
99
+ studioVersion,
100
+ contentHash,
101
+ }),
102
+ };
103
+ yield writeFile(join(outDir, 'manifest.json'),
104
+ // NOTE: the decorative fields go first so the three signed ones cannot be
105
+ // overwritten by a caller's extras.
106
+ `${JSON.stringify(Object.assign(Object.assign({}, manifestExtras), manifest), null, 2)}\n`, 'utf-8');
107
+ return manifest;
108
+ }
109
+ catch (thrown) {
110
+ // Whatever was written is a bundle in every respect but the manifest, and
111
+ // a caller can still zip and upload it.
112
+ yield removeOwnOutput(outDir).catch(() => {
113
+ throw new Error(`${thrown instanceof Error ? thrown.message : String(thrown)} ` +
114
+ `Additionally, ${outDir} could not be cleaned up — delete it by ` +
115
+ 'hand rather than uploading it; it carries no signature.');
116
+ });
117
+ throw thrown;
118
+ }
119
+ });
120
+ }
121
+ /** Fails now on a value `JSON.stringify` would reject after the bundle is signed. */
122
+ function assertSerialisable(extras) {
123
+ try {
124
+ JSON.stringify(extras);
125
+ }
126
+ catch (thrown) {
127
+ throw new Error('manifestExtras must be JSON-serialisable ' +
128
+ `(${thrown instanceof Error ? thrown.message : String(thrown)})`);
129
+ }
130
+ }
131
+ /**
132
+ * @description Empties the output directory of previous build output, and
133
+ * refuses to touch anything else.
134
+ *
135
+ * NOTE: `outDir` comes from a caller's flag, so nothing is deleted recursively
136
+ * — `--out src` would take the source tree with it. Only the exact files this
137
+ * builder writes are removed, and any other file at any depth stops the build:
138
+ * a leftover lands in `content_hash` and then has to be in the zip for the
139
+ * signature to match, which quietly makes it part of the bundle.
140
+ *
141
+ * NOTE: matching those names is not enough to claim the directory. `index.html`
142
+ * beside `assets/bundle.js` is also what a hand-written static site looks like,
143
+ * and deleting one would be unrecoverable. A `manifest.json` carrying the three
144
+ * signed fields is the proof, because only this builder writes one.
145
+ */
146
+ function prepareOutDir(outDir) {
147
+ return __awaiter(this, void 0, void 0, function* () {
148
+ var _a;
149
+ // NOTE: names only. Reading contents here — as readBundleFiles does — would
150
+ // pull the whole directory into memory to decide it is the wrong directory,
151
+ // so `--out .` in a repository would run out of memory instead of refusing.
152
+ const entries = yield readdir(outDir, {
153
+ recursive: true,
154
+ withFileTypes: true,
155
+ }).catch((thrown) => {
156
+ // Only "it is not there yet" means there is nothing to check. Swallowing
157
+ // every error would turn a permission problem into a silent build over
158
+ // whatever the directory held.
159
+ if (thrown.code === 'ENOENT')
160
+ return null;
161
+ throw thrown;
162
+ });
163
+ if (entries == null)
164
+ return;
165
+ const unexpected = [];
166
+ const junk = [];
167
+ const own = [];
168
+ for (const entry of entries) {
169
+ if (!entry.isFile())
170
+ continue;
171
+ const parent = (_a = entry.parentPath) !== null && _a !== void 0 ? _a : entry.path;
172
+ const path = relative(outDir, join(parent, entry.name))
173
+ .split(/[\\/]/)
174
+ .join(posix.sep);
175
+ if (OWN_OUTPUT.has(path)) {
176
+ own.push(path);
177
+ continue;
178
+ }
179
+ // NOTE: collected, not deleted yet. Removing junk from a directory the
180
+ // next few lines may refuse to touch would contradict the promise above.
181
+ if (JUNK_FILES.has(entry.name)) {
182
+ junk.push(join(parent, entry.name));
183
+ continue;
184
+ }
185
+ unexpected.push(path);
186
+ if (unexpected.length > UNEXPECTED_SHOWN)
187
+ break;
188
+ }
189
+ if (unexpected.length > 0) {
190
+ const shown = unexpected.slice(0, UNEXPECTED_SHOWN).sort().join(', ');
191
+ const more = unexpected.length > UNEXPECTED_SHOWN ? ', and more' : '';
192
+ throw new Error(`${outDir} holds files this build did not produce ` +
193
+ `(${shown}${more}). They would be hashed into the bundle. ` +
194
+ 'Point --out at an empty directory, or clear it first.');
195
+ }
196
+ if (own.length > 0 && !(yield hasOwnManifest(outDir))) {
197
+ throw new Error(`${outDir} holds ${own.sort().join(', ')} but no manifest.json this ` +
198
+ 'builder wrote, so it is not a previous build of this bundle and will ' +
199
+ 'not be overwritten. Point --out at an empty directory, or clear it ' +
200
+ 'first.');
201
+ }
202
+ for (const path of junk) {
203
+ yield rm(path, { force: true });
204
+ }
205
+ yield removeOwnOutput(outDir);
206
+ });
207
+ }
208
+ /** Whether the directory carries a manifest only this builder could have written. */
209
+ function hasOwnManifest(outDir) {
210
+ return __awaiter(this, void 0, void 0, function* () {
211
+ const raw = yield readFile(join(outDir, 'manifest.json'), 'utf-8').catch(() => null);
212
+ if (raw == null)
213
+ return false;
214
+ try {
215
+ const parsed = JSON.parse(raw);
216
+ return ['studio_version', 'content_hash', 'signature'].every(field => typeof parsed[field] === 'string' && parsed[field] !== '');
217
+ }
218
+ catch (_a) {
219
+ return false;
220
+ }
221
+ });
222
+ }
223
+ /**
224
+ * @description Deletes the files this builder writes, and nothing else.
225
+ *
226
+ * NOTE: separate from {@link prepareOutDir} because the failure path needs the
227
+ * removal without the validation. Reusing the checking function there would
228
+ * throw on any stray file and leave the unsigned bundle exactly where the
229
+ * contract promises it will not be.
230
+ */
231
+ function removeOwnOutput(outDir) {
232
+ return __awaiter(this, void 0, void 0, function* () {
233
+ for (const path of OWN_OUTPUT) {
234
+ yield rm(join(outDir, path), { force: true });
235
+ }
236
+ });
237
+ }
238
+ /** The host page for the bundle: the script, and the stylesheet if there is one. */
239
+ function renderIndexHtml(title, hasStyles) {
240
+ const stylesheet = hasStyles
241
+ ? `\n <link rel="stylesheet" href="./${STYLES_PATH}" />`
242
+ : '';
243
+ return `<!doctype html>
244
+ <html lang="ja">
245
+ <head>
246
+ <meta charset="utf-8" />
247
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
248
+ <title>${escapeHtml(title)}</title>${stylesheet}
249
+ </head>
250
+ <body>
251
+ <div id="root"></div>
252
+ <script type="module" src="./${BUNDLE_PATH}"></script>
253
+ </body>
254
+ </html>
255
+ `;
256
+ }
257
+ function escapeHtml(value) {
258
+ return value
259
+ .replace(/&/g, '&amp;')
260
+ .replace(/</g, '&lt;')
261
+ .replace(/>/g, '&gt;')
262
+ .replace(/"/g, '&quot;');
263
+ }