@notion-headless-cms/core 0.3.12 → 0.3.14
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/dist/cache/memory.d.mts +1 -1
- package/dist/cache/memory.mjs +1 -1
- package/dist/cache/memory.mjs.map +1 -1
- package/dist/{content-CYf6NtCC.d.mts → content-D7PfY-bV.d.mts} +17 -9
- package/dist/errors-CC_x98vG.d.mts +84 -0
- package/dist/errors.d.mts +2 -56
- package/dist/errors.mjs +23 -1
- package/dist/errors.mjs.map +1 -1
- package/dist/{hooks-D3omfgl4.d.mts → hooks-C4HRqHLo.d.mts} +12 -2
- package/dist/hooks.d.mts +1 -1
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.d.mts +98 -68
- package/dist/index.mjs +286 -173
- package/dist/index.mjs.map +1 -1
- package/dist/{memory-Cp-dnNGC.d.mts → memory-BKDsuGVN.d.mts} +3 -3
- package/package.json +2 -4
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memoryCache } from "./cache/memory.mjs";
|
|
2
|
-
import { CMSError, isCMSError, isCMSErrorInNamespace } from "./errors.mjs";
|
|
2
|
+
import { CMSError, isCMSError, isCMSErrorInNamespace, matchCMSError } from "./errors.mjs";
|
|
3
3
|
import { mergeHooks, mergeLoggers } from "./hooks.mjs";
|
|
4
4
|
//#region src/cache.ts
|
|
5
5
|
/** 文字列をSHA-256でハッシュ化し、16進数文字列として返す。画像キーの生成に使用。 */
|
|
@@ -59,44 +59,37 @@ const noopDocOps = noopDoc;
|
|
|
59
59
|
const noopImgOps = noopImg;
|
|
60
60
|
//#endregion
|
|
61
61
|
//#region src/image.ts
|
|
62
|
-
/** レスポンスヘッダまたはURLの拡張子からContent-Typeを推測する。 */
|
|
63
|
-
function inferContentType(url, responseContentType) {
|
|
64
|
-
if (responseContentType?.startsWith("image/")) return responseContentType.split(";")[0].trim();
|
|
65
|
-
if (url.includes(".png")) return "image/png";
|
|
66
|
-
if (url.includes(".gif")) return "image/gif";
|
|
67
|
-
if (url.includes(".webp")) return "image/webp";
|
|
68
|
-
return "image/jpeg";
|
|
69
|
-
}
|
|
70
62
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* メモリリーク防止に最大エントリ数を設けており、超過時は最古から削除する LRU。
|
|
63
|
+
* レスポンスの Content-Type ヘッダから画像の MIME タイプを取り出す。
|
|
64
|
+
* ヘッダがない、または image/* でない場合は CMSError を投げる。
|
|
65
|
+
* URL 拡張子からの推測や jpeg デフォルトは行わない。
|
|
76
66
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
function pickImageContentType(headerValue, notionUrl) {
|
|
68
|
+
if (!headerValue) throw new CMSError({
|
|
69
|
+
code: "cache/image_invalid_content_type",
|
|
70
|
+
message: "Image response missing Content-Type header.",
|
|
71
|
+
context: {
|
|
72
|
+
operation: "fetchAndCacheImage:contentType",
|
|
73
|
+
notionUrl
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const value = (headerValue.split(";")[0] ?? headerValue).trim().toLowerCase();
|
|
77
|
+
if (!value.startsWith("image/")) throw new CMSError({
|
|
78
|
+
code: "cache/image_invalid_content_type",
|
|
79
|
+
message: `Image response has non-image Content-Type: ${value}`,
|
|
80
|
+
context: {
|
|
81
|
+
operation: "fetchAndCacheImage:contentType",
|
|
82
|
+
notionUrl,
|
|
83
|
+
contentType: value
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return value;
|
|
93
87
|
}
|
|
94
88
|
/**
|
|
95
89
|
* Notion画像URLをfetchして ImageCacheOps にキャッシュし、プロキシURL を返す。
|
|
96
90
|
* 既存キャッシュがあれば再fetchしない。
|
|
97
91
|
*/
|
|
98
|
-
async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger) {
|
|
99
|
-
const hash = await memoSha256(notionUrl);
|
|
92
|
+
async function fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger) {
|
|
100
93
|
const proxyUrl = `${imageProxyBase}/${hash}`;
|
|
101
94
|
if (await cache.get(hash)) {
|
|
102
95
|
logger?.debug?.("画像キャッシュヒット", {
|
|
@@ -123,7 +116,7 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
|
|
|
123
116
|
}
|
|
124
117
|
});
|
|
125
118
|
const data = await response.arrayBuffer();
|
|
126
|
-
const contentType =
|
|
119
|
+
const contentType = pickImageContentType(response.headers.get("content-type"), notionUrl);
|
|
127
120
|
await cache.set(hash, data, contentType);
|
|
128
121
|
logger?.debug?.("画像をキャッシュに保存", {
|
|
129
122
|
operation: "fetchAndCacheImage",
|
|
@@ -148,13 +141,39 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
|
|
|
148
141
|
* `ImageCacheOps` と `imageProxyBase` から `cacheImage` 関数を構築する。
|
|
149
142
|
* 返り値は Notion 画像 URL を受け取り、SHA-256 ハッシュをキャッシュキーとして
|
|
150
143
|
* {@link ImageCacheOps} に保存後、プロキシ URL を返す。
|
|
144
|
+
*
|
|
145
|
+
* ハッシュのメモ化はファクトリ呼び出し単位でスコープ化されており、
|
|
146
|
+
* インスタンス間でキャッシュを共有しない。
|
|
151
147
|
*/
|
|
152
148
|
function buildCacheImageFn(cache, cacheName, imageProxyBase, logger) {
|
|
153
|
-
|
|
149
|
+
const hashMemo = /* @__PURE__ */ new Map();
|
|
150
|
+
return async (notionUrl) => {
|
|
151
|
+
let hash = hashMemo.get(notionUrl);
|
|
152
|
+
if (hash === void 0) {
|
|
153
|
+
hash = await sha256Hex(notionUrl);
|
|
154
|
+
hashMemo.set(notionUrl, hash);
|
|
155
|
+
}
|
|
156
|
+
return fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger);
|
|
157
|
+
};
|
|
154
158
|
}
|
|
155
159
|
//#endregion
|
|
156
160
|
//#region src/rendering.ts
|
|
157
161
|
/**
|
|
162
|
+
* `@notion-headless-cms/renderer` を動的 import してデフォルトレンダラーを返す。
|
|
163
|
+
* core のゼロ依存ルールを守るため静的 import は禁止。
|
|
164
|
+
*/
|
|
165
|
+
async function loadDefaultRenderer() {
|
|
166
|
+
try {
|
|
167
|
+
return (await import("@notion-headless-cms/renderer")).renderMarkdown;
|
|
168
|
+
} catch {
|
|
169
|
+
throw new CMSError({
|
|
170
|
+
code: "core/config_invalid",
|
|
171
|
+
message: "renderer が未指定で、@notion-headless-cms/renderer のロードにも失敗しました。 createCMS の renderer オプションを指定するか、@notion-headless-cms/renderer をインストールしてください。",
|
|
172
|
+
context: { operation: "loadDefaultRenderer" }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
158
177
|
* メタデータキャッシュエントリを生成する。Notion API も renderer も呼ばない軽量関数。
|
|
159
178
|
*/
|
|
160
179
|
function buildCachedItemMeta(item, source) {
|
|
@@ -191,19 +210,25 @@ async function buildCachedItemContent(item, ctx) {
|
|
|
191
210
|
}
|
|
192
211
|
});
|
|
193
212
|
}
|
|
194
|
-
let blocks
|
|
213
|
+
let blocks;
|
|
195
214
|
try {
|
|
196
215
|
blocks = await ctx.source.loadBlocks(item);
|
|
197
216
|
} catch (err) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
if (isCMSError(err)) throw err;
|
|
218
|
+
throw new CMSError({
|
|
219
|
+
code: "source/load_blocks_failed",
|
|
220
|
+
message: "Failed to load blocks from source.",
|
|
221
|
+
cause: err,
|
|
222
|
+
context: {
|
|
223
|
+
operation: "buildCachedItemContent:loadBlocks",
|
|
224
|
+
pageId: item.id,
|
|
225
|
+
slug: item.slug
|
|
226
|
+
}
|
|
201
227
|
});
|
|
202
|
-
blocks = [];
|
|
203
228
|
}
|
|
204
229
|
const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imgCacheName, ctx.imageProxyBase, ctx.logger) : void 0;
|
|
205
|
-
let html;
|
|
206
230
|
const rendererFn = ctx.rendererFn ?? await loadDefaultRenderer();
|
|
231
|
+
let html;
|
|
207
232
|
try {
|
|
208
233
|
html = await rendererFn(markdown, {
|
|
209
234
|
imageProxyBase: ctx.imageProxyBase,
|
|
@@ -241,23 +266,6 @@ async function buildCachedItemContent(item, ctx) {
|
|
|
241
266
|
ctx.hooks.onRenderEnd?.(item.slug, durationMs);
|
|
242
267
|
return result;
|
|
243
268
|
}
|
|
244
|
-
/**
|
|
245
|
-
* renderer オプション未指定時のフォールバック。
|
|
246
|
-
* @notion-headless-cms/renderer を動的 import する。
|
|
247
|
-
* createCMS({ renderer }) で明示注入された場合はこのパスを通らない。
|
|
248
|
-
*/
|
|
249
|
-
async function loadDefaultRenderer() {
|
|
250
|
-
try {
|
|
251
|
-
return (await import("@notion-headless-cms/renderer")).renderMarkdown;
|
|
252
|
-
} catch (err) {
|
|
253
|
-
throw new CMSError({
|
|
254
|
-
code: "renderer/failed",
|
|
255
|
-
message: "renderer オプションが未指定で @notion-headless-cms/renderer が見つかりません。 createCMS({ renderer }) でレンダラーを注入するか、@notion-headless-cms/renderer をインストールしてください。",
|
|
256
|
-
cause: err,
|
|
257
|
-
context: { operation: "loadDefaultRenderer" }
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
269
|
//#endregion
|
|
262
270
|
//#region src/retry.ts
|
|
263
271
|
const DEFAULT_RETRY_CONFIG = {
|
|
@@ -313,40 +321,40 @@ var CollectionClientImpl = class {
|
|
|
313
321
|
constructor(ctx) {
|
|
314
322
|
this.ctx = ctx;
|
|
315
323
|
this.cache = {
|
|
316
|
-
invalidate: (
|
|
317
|
-
|
|
318
|
-
|
|
324
|
+
invalidate: () => this.invalidateImpl(),
|
|
325
|
+
invalidateItem: (slug) => this.invalidateItemImpl(slug),
|
|
326
|
+
warm: (opts) => this.warmImpl(opts)
|
|
319
327
|
};
|
|
320
328
|
}
|
|
321
|
-
async
|
|
322
|
-
if (opts.
|
|
329
|
+
async find(slug, opts = {}) {
|
|
330
|
+
if (opts.bypassCache) {
|
|
323
331
|
this.ctx.hooks.onCacheMiss?.(slug);
|
|
324
|
-
const item = await this.
|
|
332
|
+
const item = await this.fetchRaw(slug);
|
|
325
333
|
if (!item) return null;
|
|
326
334
|
const meta = await this.persistMeta(slug, item);
|
|
327
|
-
await this.
|
|
335
|
+
await this.invalidateContentEntry(slug);
|
|
328
336
|
return this.attachLazyContent(meta);
|
|
329
337
|
}
|
|
330
338
|
const cachedMeta = await this.ctx.docCache.getMeta(this.ctx.collection, slug);
|
|
331
339
|
if (cachedMeta) {
|
|
332
340
|
if (this.ctx.ttlMs !== void 0 && isStale(cachedMeta.cachedAt, this.ctx.ttlMs)) {
|
|
333
341
|
this.ctx.logger?.debug?.("キャッシュ期限切れ(TTL)、フェッチ", {
|
|
334
|
-
operation: "
|
|
342
|
+
operation: "find",
|
|
335
343
|
slug,
|
|
336
344
|
collection: this.ctx.collection,
|
|
337
345
|
cacheAdapter: this.ctx.docCacheName
|
|
338
346
|
});
|
|
339
347
|
this.ctx.hooks.onCacheMiss?.(slug);
|
|
340
|
-
const item = await this.
|
|
348
|
+
const item = await this.fetchRaw(slug);
|
|
341
349
|
if (!item) return null;
|
|
342
350
|
const meta = await this.persistMeta(slug, item);
|
|
343
|
-
await this.
|
|
351
|
+
await this.invalidateContentEntry(slug);
|
|
344
352
|
return this.attachLazyContent(meta);
|
|
345
353
|
}
|
|
346
354
|
const bg = this.checkAndUpdateItemBg(slug, cachedMeta);
|
|
347
355
|
if (this.ctx.waitUntil) this.ctx.waitUntil(bg);
|
|
348
356
|
this.ctx.logger?.debug?.("キャッシュヒット", {
|
|
349
|
-
operation: "
|
|
357
|
+
operation: "find",
|
|
350
358
|
slug,
|
|
351
359
|
collection: this.ctx.collection,
|
|
352
360
|
cacheAdapter: this.ctx.docCacheName,
|
|
@@ -356,13 +364,13 @@ var CollectionClientImpl = class {
|
|
|
356
364
|
return this.attachLazyContent(cachedMeta);
|
|
357
365
|
}
|
|
358
366
|
this.ctx.logger?.debug?.("キャッシュミス、フェッチ", {
|
|
359
|
-
operation: "
|
|
367
|
+
operation: "find",
|
|
360
368
|
slug,
|
|
361
369
|
collection: this.ctx.collection,
|
|
362
370
|
cacheAdapter: this.ctx.docCacheName
|
|
363
371
|
});
|
|
364
372
|
this.ctx.hooks.onCacheMiss?.(slug);
|
|
365
|
-
const item = await this.
|
|
373
|
+
const item = await this.fetchRaw(slug);
|
|
366
374
|
if (!item) return null;
|
|
367
375
|
const meta = await this.persistMeta(slug, item, { background: true });
|
|
368
376
|
return this.attachLazyContent(meta);
|
|
@@ -371,33 +379,32 @@ var CollectionClientImpl = class {
|
|
|
371
379
|
return applyListOptions(await this.fetchList(), opts);
|
|
372
380
|
}
|
|
373
381
|
async params() {
|
|
374
|
-
return (await this.fetchList()).map((item) =>
|
|
382
|
+
return (await this.fetchList()).map((item) => item.slug);
|
|
375
383
|
}
|
|
376
384
|
async check(slug, currentVersion) {
|
|
377
|
-
const raw = await this.
|
|
385
|
+
const raw = await this.fetchRaw(slug);
|
|
378
386
|
if (!raw) return null;
|
|
379
|
-
if (raw.
|
|
387
|
+
if (raw.lastEditedTime === currentVersion) return { stale: false };
|
|
380
388
|
const meta = await this.persistMeta(slug, raw);
|
|
381
|
-
await this.
|
|
389
|
+
await this.invalidateContentEntry(slug);
|
|
382
390
|
return {
|
|
383
391
|
stale: true,
|
|
384
392
|
item: this.attachLazyContent(meta)
|
|
385
393
|
};
|
|
386
394
|
}
|
|
387
|
-
async
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
395
|
+
async adjacent(slug, opts) {
|
|
396
|
+
const items = applyListOptions(await this.fetchList(), { sort: opts?.sort });
|
|
397
|
+
const index = items.findIndex((it) => it.slug === slug);
|
|
398
|
+
if (index === -1) return {
|
|
399
|
+
prev: null,
|
|
400
|
+
next: null
|
|
401
|
+
};
|
|
402
|
+
return {
|
|
403
|
+
prev: index > 0 ? items[index - 1] ?? null : null,
|
|
404
|
+
next: index < items.length - 1 ? items[index + 1] ?? null : null
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async invalidateImpl() {
|
|
401
408
|
this.ctx.logger?.debug?.("コレクション全体のキャッシュを無効化", {
|
|
402
409
|
operation: "cache.invalidate",
|
|
403
410
|
collection: this.ctx.collection,
|
|
@@ -405,11 +412,23 @@ var CollectionClientImpl = class {
|
|
|
405
412
|
});
|
|
406
413
|
await this.ctx.docCache.invalidate({ collection: this.ctx.collection });
|
|
407
414
|
}
|
|
415
|
+
async invalidateItemImpl(slug) {
|
|
416
|
+
this.ctx.logger?.debug?.("アイテムキャッシュを無効化", {
|
|
417
|
+
operation: "cache.invalidateItem",
|
|
418
|
+
collection: this.ctx.collection,
|
|
419
|
+
cacheAdapter: this.ctx.docCacheName,
|
|
420
|
+
slug
|
|
421
|
+
});
|
|
422
|
+
await this.ctx.docCache.invalidate({
|
|
423
|
+
collection: this.ctx.collection,
|
|
424
|
+
slug
|
|
425
|
+
});
|
|
426
|
+
}
|
|
408
427
|
async warmImpl(opts) {
|
|
409
428
|
const items = await this.fetchListRaw();
|
|
410
429
|
const concurrency = opts?.concurrency ?? this.ctx.maxConcurrent;
|
|
411
430
|
let ok = 0;
|
|
412
|
-
|
|
431
|
+
const failed = [];
|
|
413
432
|
for (let i = 0; i < items.length; i += concurrency) {
|
|
414
433
|
const chunk = items.slice(i, i + concurrency);
|
|
415
434
|
await Promise.all(chunk.map(async (item) => {
|
|
@@ -419,7 +438,10 @@ var CollectionClientImpl = class {
|
|
|
419
438
|
await this.ctx.docCache.setContent(this.ctx.collection, item.slug, content);
|
|
420
439
|
ok++;
|
|
421
440
|
} catch (err) {
|
|
422
|
-
failed
|
|
441
|
+
failed.push({
|
|
442
|
+
slug: item.slug,
|
|
443
|
+
error: err
|
|
444
|
+
});
|
|
423
445
|
this.ctx.logger?.warn?.("warm: アイテムの事前レンダリングに失敗", {
|
|
424
446
|
slug: item.slug,
|
|
425
447
|
pageId: item.id,
|
|
@@ -438,18 +460,6 @@ var CollectionClientImpl = class {
|
|
|
438
460
|
failed
|
|
439
461
|
};
|
|
440
462
|
}
|
|
441
|
-
async adjacentImpl(slug, opts) {
|
|
442
|
-
const items = applyListOptions(await this.fetchList(), { sort: opts?.sort });
|
|
443
|
-
const index = items.findIndex((it) => it.slug === slug);
|
|
444
|
-
if (index === -1) return {
|
|
445
|
-
prev: null,
|
|
446
|
-
next: null
|
|
447
|
-
};
|
|
448
|
-
return {
|
|
449
|
-
prev: index > 0 ? items[index - 1] ?? null : null,
|
|
450
|
-
next: index < items.length - 1 ? items[index + 1] ?? null : null
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
463
|
async persistMeta(slug, item, opts = {}) {
|
|
454
464
|
let meta = buildCachedItemMeta(item, this.ctx.source);
|
|
455
465
|
if (this.ctx.hooks.beforeCacheMeta) meta = await this.ctx.hooks.beforeCacheMeta(meta);
|
|
@@ -458,7 +468,7 @@ var CollectionClientImpl = class {
|
|
|
458
468
|
else await save;
|
|
459
469
|
return meta;
|
|
460
470
|
}
|
|
461
|
-
async
|
|
471
|
+
async invalidateContentEntry(slug) {
|
|
462
472
|
await this.ctx.docCache.invalidate({
|
|
463
473
|
collection: this.ctx.collection,
|
|
464
474
|
slug,
|
|
@@ -478,16 +488,31 @@ var CollectionClientImpl = class {
|
|
|
478
488
|
this.ctx.hooks.onContentRevalidated?.(slug, fresh);
|
|
479
489
|
return fresh;
|
|
480
490
|
}
|
|
481
|
-
/**
|
|
491
|
+
/** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは onSwrError フックに通知する。 */
|
|
482
492
|
async rebuildContentBg(slug, item) {
|
|
483
493
|
try {
|
|
484
494
|
const fresh = await buildCachedItemContent(item, this.ctx.render);
|
|
485
495
|
await this.ctx.docCache.setContent(this.ctx.collection, slug, fresh);
|
|
486
496
|
this.ctx.hooks.onContentRevalidated?.(slug, fresh);
|
|
487
497
|
} catch (err) {
|
|
498
|
+
const cmsErr = isCMSError(err) ? err : new CMSError({
|
|
499
|
+
code: "swr/content_rebuild_failed",
|
|
500
|
+
message: "SWR background content rebuild failed.",
|
|
501
|
+
cause: err,
|
|
502
|
+
context: {
|
|
503
|
+
operation: "swr.rebuildContentBg",
|
|
504
|
+
collection: this.ctx.collection,
|
|
505
|
+
slug
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
this.ctx.hooks.onSwrError?.(cmsErr, {
|
|
509
|
+
phase: "item-content",
|
|
510
|
+
slug
|
|
511
|
+
});
|
|
488
512
|
this.ctx.logger?.warn?.("本文のバックグラウンド再生成に失敗", {
|
|
489
513
|
slug,
|
|
490
514
|
collection: this.ctx.collection,
|
|
515
|
+
code: cmsErr.code,
|
|
491
516
|
error: err instanceof Error ? err.message : String(err)
|
|
492
517
|
});
|
|
493
518
|
}
|
|
@@ -500,11 +525,11 @@ var CollectionClientImpl = class {
|
|
|
500
525
|
if (!payloadPromise) payloadPromise = this.loadOrBuildContent(slug, item);
|
|
501
526
|
return payloadPromise;
|
|
502
527
|
};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
528
|
+
return Object.assign(Object.create(null), item, {
|
|
529
|
+
html: async () => (await loadPayload()).html,
|
|
530
|
+
markdown: async () => (await loadPayload()).markdown,
|
|
531
|
+
blocks: async () => (await loadPayload()).blocks
|
|
532
|
+
});
|
|
508
533
|
}
|
|
509
534
|
async fetchList() {
|
|
510
535
|
const cached = await this.ctx.docCache.getList(this.ctx.collection);
|
|
@@ -551,13 +576,13 @@ var CollectionClientImpl = class {
|
|
|
551
576
|
}
|
|
552
577
|
async checkAndUpdateItemBg(slug, cached) {
|
|
553
578
|
try {
|
|
554
|
-
const item = await this.
|
|
579
|
+
const item = await this.fetchRaw(slug);
|
|
555
580
|
if (!item) return;
|
|
556
581
|
if (this.ctx.source.getLastModified(item) !== cached.notionUpdatedAt) {
|
|
557
582
|
const meta = await this.persistMeta(slug, item);
|
|
558
|
-
await this.
|
|
583
|
+
await this.invalidateContentEntry(slug);
|
|
559
584
|
this.ctx.logger?.debug?.("SWR: 差分を検出、メタを差し替え", {
|
|
560
|
-
operation: "
|
|
585
|
+
operation: "find:bg",
|
|
561
586
|
slug,
|
|
562
587
|
collection: this.ctx.collection,
|
|
563
588
|
notionUpdatedAt: cached.notionUpdatedAt
|
|
@@ -570,15 +595,30 @@ var CollectionClientImpl = class {
|
|
|
570
595
|
cachedAt: Date.now()
|
|
571
596
|
});
|
|
572
597
|
this.ctx.logger?.debug?.("SWR: 差分なし、TTL をリセット", {
|
|
573
|
-
operation: "
|
|
598
|
+
operation: "find:bg",
|
|
574
599
|
slug,
|
|
575
600
|
collection: this.ctx.collection
|
|
576
601
|
});
|
|
577
602
|
}
|
|
578
603
|
} catch (err) {
|
|
604
|
+
const cmsErr = isCMSError(err) ? err : new CMSError({
|
|
605
|
+
code: "swr/item_check_failed",
|
|
606
|
+
message: "SWR background item check failed.",
|
|
607
|
+
cause: err,
|
|
608
|
+
context: {
|
|
609
|
+
operation: "swr.checkAndUpdateItemBg",
|
|
610
|
+
collection: this.ctx.collection,
|
|
611
|
+
slug
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
this.ctx.hooks.onSwrError?.(cmsErr, {
|
|
615
|
+
phase: "item-meta",
|
|
616
|
+
slug
|
|
617
|
+
});
|
|
579
618
|
this.ctx.logger?.warn?.("SWR: アイテムのバックグラウンド差分チェックに失敗", {
|
|
580
619
|
slug,
|
|
581
620
|
collection: this.ctx.collection,
|
|
621
|
+
code: cmsErr.code,
|
|
582
622
|
error: err instanceof Error ? err.message : String(err)
|
|
583
623
|
});
|
|
584
624
|
}
|
|
@@ -608,14 +648,25 @@ var CollectionClientImpl = class {
|
|
|
608
648
|
});
|
|
609
649
|
}
|
|
610
650
|
} catch (err) {
|
|
651
|
+
const cmsErr = isCMSError(err) ? err : new CMSError({
|
|
652
|
+
code: "swr/list_check_failed",
|
|
653
|
+
message: "SWR background list check failed.",
|
|
654
|
+
cause: err,
|
|
655
|
+
context: {
|
|
656
|
+
operation: "swr.checkAndUpdateListBg",
|
|
657
|
+
collection: this.ctx.collection
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
this.ctx.hooks.onSwrError?.(cmsErr, { phase: "list" });
|
|
611
661
|
this.ctx.logger?.warn?.("SWR: リストのバックグラウンド差分チェックに失敗", {
|
|
612
662
|
collection: this.ctx.collection,
|
|
663
|
+
code: cmsErr.code,
|
|
613
664
|
error: err instanceof Error ? err.message : String(err)
|
|
614
665
|
});
|
|
615
666
|
}
|
|
616
667
|
}
|
|
617
|
-
fetchListRaw() {
|
|
618
|
-
return withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
|
|
668
|
+
async fetchListRaw() {
|
|
669
|
+
return (await withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
|
|
619
670
|
...this.ctx.retryConfig,
|
|
620
671
|
onRetry: (attempt, status) => {
|
|
621
672
|
this.ctx.logger?.warn?.("list() リトライ中", {
|
|
@@ -623,13 +674,17 @@ var CollectionClientImpl = class {
|
|
|
623
674
|
status
|
|
624
675
|
});
|
|
625
676
|
}
|
|
677
|
+
})).filter((item) => {
|
|
678
|
+
if (item.isArchived || item.isInTrash) return false;
|
|
679
|
+
if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return false;
|
|
680
|
+
return true;
|
|
626
681
|
});
|
|
627
682
|
}
|
|
628
|
-
async
|
|
683
|
+
async fetchRaw(slug) {
|
|
629
684
|
const retryOpts = {
|
|
630
685
|
...this.ctx.retryConfig,
|
|
631
686
|
onRetry: (attempt, status) => {
|
|
632
|
-
this.ctx.logger?.warn?.("
|
|
687
|
+
this.ctx.logger?.warn?.("find() リトライ中", {
|
|
633
688
|
attempt,
|
|
634
689
|
status,
|
|
635
690
|
slug
|
|
@@ -642,15 +697,26 @@ var CollectionClientImpl = class {
|
|
|
642
697
|
if (notionPropName && findByProp) item = await withRetry(() => findByProp(notionPropName, slug), retryOpts);
|
|
643
698
|
else item = (await withRetry(() => this.ctx.source.list(), retryOpts)).find((i) => i.slug === slug) ?? null;
|
|
644
699
|
if (!item) return null;
|
|
700
|
+
if (item.isArchived || item.isInTrash) return null;
|
|
645
701
|
if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return null;
|
|
646
702
|
return item;
|
|
647
703
|
}
|
|
648
704
|
};
|
|
705
|
+
function matchesWhere(item, where) {
|
|
706
|
+
for (const key of Object.keys(where)) {
|
|
707
|
+
const expected = where[key];
|
|
708
|
+
const actual = item[key];
|
|
709
|
+
if (Array.isArray(expected)) {
|
|
710
|
+
if (!expected.includes(actual)) return false;
|
|
711
|
+
} else if (actual !== expected) return false;
|
|
712
|
+
}
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
649
715
|
function applyListOptions(items, opts) {
|
|
650
|
-
if (!opts) return items;
|
|
716
|
+
if (!opts) return sortByPublishedAtDesc(items);
|
|
651
717
|
let result = items;
|
|
652
|
-
if (opts.
|
|
653
|
-
const allow = new Set(Array.isArray(opts.
|
|
718
|
+
if (opts.statuses) {
|
|
719
|
+
const allow = new Set(Array.isArray(opts.statuses) ? opts.statuses : [opts.statuses]);
|
|
654
720
|
result = result.filter((it) => it.status != null && allow.has(it.status));
|
|
655
721
|
}
|
|
656
722
|
if (opts.tag) {
|
|
@@ -662,24 +728,46 @@ function applyListOptions(items, opts) {
|
|
|
662
728
|
}
|
|
663
729
|
if (opts.where) {
|
|
664
730
|
const where = opts.where;
|
|
665
|
-
result = result.filter((it) =>
|
|
731
|
+
result = result.filter((it) => matchesWhere(it, where));
|
|
666
732
|
}
|
|
733
|
+
if (opts.filter) result = result.filter(opts.filter);
|
|
667
734
|
if (opts.sort) result = [...result].sort(makeComparator(opts.sort));
|
|
735
|
+
else result = sortByPublishedAtDesc(result);
|
|
668
736
|
const skip = opts.skip ?? 0;
|
|
669
737
|
const limit = opts.limit;
|
|
670
738
|
if (skip > 0 || limit !== void 0) result = result.slice(skip, limit !== void 0 ? skip + limit : void 0);
|
|
671
739
|
return result;
|
|
672
740
|
}
|
|
741
|
+
/** publishedAt 降順、未設定の場合は lastEditedTime 降順でソートする。 */
|
|
742
|
+
function sortByPublishedAtDesc(items) {
|
|
743
|
+
return [...items].sort((a, b) => {
|
|
744
|
+
const av = a.publishedAt ?? a.lastEditedTime;
|
|
745
|
+
const bv = b.publishedAt ?? b.lastEditedTime;
|
|
746
|
+
if (av === bv) return 0;
|
|
747
|
+
return av > bv ? -1 : 1;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
673
750
|
function makeComparator(sort) {
|
|
751
|
+
if (sort.compare) return sort.compare;
|
|
674
752
|
const by = sort.by;
|
|
675
753
|
const dir = sort.dir === "asc" ? 1 : -1;
|
|
676
754
|
return (a, b) => {
|
|
677
755
|
const av = a[by];
|
|
678
756
|
const bv = b[by];
|
|
679
757
|
if (av === bv) return 0;
|
|
680
|
-
if (av === void 0) return 1;
|
|
681
|
-
if (bv === void 0) return -1;
|
|
682
|
-
return av > bv ? dir : -dir;
|
|
758
|
+
if (av === void 0 || av === null) return 1;
|
|
759
|
+
if (bv === void 0 || bv === null) return -1;
|
|
760
|
+
if (typeof av === "string" && typeof bv === "string") return av > bv ? dir : -dir;
|
|
761
|
+
if (typeof av === "number" && typeof bv === "number") return av > bv ? dir : -dir;
|
|
762
|
+
throw new CMSError({
|
|
763
|
+
code: "core/sort_unsupported_type",
|
|
764
|
+
message: `"${String(by)}" フィールドの型 "${typeof av}" はソート非対応です。compare 関数を指定してください。`,
|
|
765
|
+
context: {
|
|
766
|
+
operation: "makeComparator",
|
|
767
|
+
field: String(by),
|
|
768
|
+
type: typeof av
|
|
769
|
+
}
|
|
770
|
+
});
|
|
683
771
|
};
|
|
684
772
|
}
|
|
685
773
|
//#endregion
|
|
@@ -690,12 +778,23 @@ const DEFAULT_OPTS = {
|
|
|
690
778
|
revalidatePath: "/revalidate"
|
|
691
779
|
};
|
|
692
780
|
/**
|
|
781
|
+
* CMSError のコードから HTTP ステータスコードを返す。
|
|
782
|
+
* 既知の webhook エラーコードのみ対応し、それ以外は null を返す。
|
|
783
|
+
*/
|
|
784
|
+
function webhookErrorStatus(code) {
|
|
785
|
+
if (code === "webhook/signature_invalid") return 401;
|
|
786
|
+
if (code === "webhook/not_implemented") return 501;
|
|
787
|
+
if (code === "webhook/unknown_collection") return 404;
|
|
788
|
+
if (code === "webhook/payload_invalid") return 400;
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
693
792
|
* Web Standard な Request → Response ルーター。
|
|
694
793
|
* Next.js / React Router / Hono / Cloudflare Workers いずれでも使える。
|
|
695
794
|
*
|
|
696
795
|
* ルート:
|
|
697
|
-
* - GET `{basePath}/images/:hash`
|
|
698
|
-
* - POST `{basePath}/revalidate`
|
|
796
|
+
* - GET `{basePath}/images/:hash` — 画像プロキシ
|
|
797
|
+
* - POST `{basePath}/revalidate/:collection` — Webhook 受信 + $revalidate()
|
|
699
798
|
*/
|
|
700
799
|
function createHandler(adapter, opts = {}) {
|
|
701
800
|
const basePath = trimTrailingSlash(opts.basePath ?? DEFAULT_OPTS.basePath);
|
|
@@ -715,23 +814,38 @@ function createHandler(adapter, opts = {}) {
|
|
|
715
814
|
headers.set("cache-control", "public, max-age=31536000, immutable");
|
|
716
815
|
return new Response(object.data, { headers });
|
|
717
816
|
}
|
|
718
|
-
if (req.method === "POST" && rel
|
|
719
|
-
const
|
|
720
|
-
if (!
|
|
817
|
+
if (req.method === "POST" && rel.startsWith(`${revalidatePath}/`)) {
|
|
818
|
+
const collection = rel.slice(revalidatePath.length + 1);
|
|
819
|
+
if (!collection || collection.includes("/")) return new Response(JSON.stringify({
|
|
721
820
|
ok: false,
|
|
722
|
-
reason: "
|
|
821
|
+
reason: "collection required"
|
|
723
822
|
}), {
|
|
724
823
|
status: 400,
|
|
725
824
|
headers: { "content-type": "application/json" }
|
|
726
825
|
});
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
826
|
+
try {
|
|
827
|
+
const scope = await adapter.parseWebhookFor(collection, req, opts.webhookSecret);
|
|
828
|
+
await adapter.revalidate(scope);
|
|
829
|
+
return new Response(JSON.stringify({
|
|
830
|
+
ok: true,
|
|
831
|
+
scope
|
|
832
|
+
}), {
|
|
833
|
+
status: 200,
|
|
834
|
+
headers: { "content-type": "application/json" }
|
|
835
|
+
});
|
|
836
|
+
} catch (err) {
|
|
837
|
+
if (isCMSError(err)) {
|
|
838
|
+
const status = webhookErrorStatus(err.code);
|
|
839
|
+
if (status !== null) return new Response(JSON.stringify({
|
|
840
|
+
ok: false,
|
|
841
|
+
code: err.code
|
|
842
|
+
}), {
|
|
843
|
+
status,
|
|
844
|
+
headers: { "content-type": "application/json" }
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
throw err;
|
|
848
|
+
}
|
|
735
849
|
}
|
|
736
850
|
return new Response("Not Found", { status: 404 });
|
|
737
851
|
};
|
|
@@ -745,12 +859,11 @@ const DEFAULT_IMAGE_PROXY_BASE = "/api/images";
|
|
|
745
859
|
/**
|
|
746
860
|
* `cache` オプションから document / image オペレーションを解決する。
|
|
747
861
|
*
|
|
748
|
-
* -
|
|
749
|
-
* - 単体で渡された場合は `handles` の領域だけ反映、片側は noop
|
|
862
|
+
* - 各 adapter の `handles` を見て先勝ち (最初に見つかったもの) で振り分ける
|
|
750
863
|
* - 未指定なら両方 noop
|
|
751
864
|
*/
|
|
752
865
|
function resolveCache(cache) {
|
|
753
|
-
const adapters = cache
|
|
866
|
+
const adapters = cache ?? [];
|
|
754
867
|
let doc = noopDocOps;
|
|
755
868
|
let docName = "noop-document";
|
|
756
869
|
let img = noopImgOps;
|
|
@@ -812,7 +925,8 @@ function applyLogLevel(logger, minLevel) {
|
|
|
812
925
|
* publishedStatuses: ["公開済み"],
|
|
813
926
|
* }
|
|
814
927
|
* },
|
|
815
|
-
* cache: memoryCache(
|
|
928
|
+
* cache: [memoryCache()],
|
|
929
|
+
* swr: { ttlMs: 5 * 60_000 },
|
|
816
930
|
* });
|
|
817
931
|
*/
|
|
818
932
|
function createCMS(opts) {
|
|
@@ -840,7 +954,7 @@ function createCMS(opts) {
|
|
|
840
954
|
});
|
|
841
955
|
}
|
|
842
956
|
const cacheRes = resolveCache(opts.cache);
|
|
843
|
-
const ttlMs = opts.ttlMs;
|
|
957
|
+
const ttlMs = opts.swr?.ttlMs;
|
|
844
958
|
const imageProxyBase = opts.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
|
|
845
959
|
const contentConfig = opts.content;
|
|
846
960
|
const rendererFn = opts.renderer;
|
|
@@ -853,10 +967,10 @@ function createCMS(opts) {
|
|
|
853
967
|
...DEFAULT_RETRY_CONFIG,
|
|
854
968
|
...opts.rateLimiter ?? {}
|
|
855
969
|
};
|
|
856
|
-
const collectionNames =
|
|
970
|
+
const collectionNames = [];
|
|
857
971
|
const collections = {};
|
|
858
|
-
for (const name of
|
|
859
|
-
|
|
972
|
+
for (const [name, def] of Object.entries(opts.collections)) {
|
|
973
|
+
collectionNames.push(name);
|
|
860
974
|
const source = def.source;
|
|
861
975
|
const colHooks = def.hooks;
|
|
862
976
|
const collectionHooks = colHooks ? mergeHooks([{
|
|
@@ -892,43 +1006,42 @@ function createCMS(opts) {
|
|
|
892
1006
|
});
|
|
893
1007
|
}
|
|
894
1008
|
const globalOps = {
|
|
895
|
-
|
|
896
|
-
async
|
|
1009
|
+
collections: collectionNames,
|
|
1010
|
+
async invalidate(scope) {
|
|
897
1011
|
logger?.debug?.("グローバルキャッシュを無効化", {
|
|
898
|
-
operation: "
|
|
1012
|
+
operation: "invalidate",
|
|
899
1013
|
cacheAdapter: cacheRes.docName
|
|
900
1014
|
});
|
|
901
1015
|
await cacheRes.doc.invalidate(scope ?? "all");
|
|
902
1016
|
},
|
|
903
|
-
|
|
1017
|
+
handler(handlerOpts) {
|
|
904
1018
|
return createHandler({
|
|
905
1019
|
imageCache: cacheRes.img,
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
error: err instanceof Error ? err.message : String(err)
|
|
915
|
-
});
|
|
1020
|
+
async parseWebhookFor(collection, req, webhookSecret) {
|
|
1021
|
+
const def = opts.collections[collection];
|
|
1022
|
+
if (!def) throw new CMSError({
|
|
1023
|
+
code: "webhook/unknown_collection",
|
|
1024
|
+
message: `Unknown collection: ${collection}`,
|
|
1025
|
+
context: {
|
|
1026
|
+
operation: "parseWebhookFor",
|
|
1027
|
+
collection
|
|
916
1028
|
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1029
|
+
});
|
|
1030
|
+
const ds = def.source;
|
|
1031
|
+
if (!ds.parseWebhook) throw new CMSError({
|
|
1032
|
+
code: "webhook/not_implemented",
|
|
1033
|
+
message: `Collection "${collection}" does not support webhooks.`,
|
|
1034
|
+
context: {
|
|
1035
|
+
operation: "parseWebhookFor",
|
|
1036
|
+
collection
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
return ds.parseWebhook(req, { secret: webhookSecret });
|
|
927
1040
|
},
|
|
928
|
-
revalidate: (scope) => globalOps
|
|
1041
|
+
revalidate: (scope) => globalOps.invalidate(scope)
|
|
929
1042
|
}, handlerOpts);
|
|
930
1043
|
},
|
|
931
|
-
|
|
1044
|
+
getCachedImage(hash) {
|
|
932
1045
|
return cacheRes.img.get(hash);
|
|
933
1046
|
}
|
|
934
1047
|
};
|
|
@@ -940,6 +1053,6 @@ function definePlugin(plugin) {
|
|
|
940
1053
|
return plugin;
|
|
941
1054
|
}
|
|
942
1055
|
//#endregion
|
|
943
|
-
export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, memoryCache, mergeHooks, mergeLoggers, noopDocOps, noopImgOps, sha256Hex, withRetry };
|
|
1056
|
+
export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, matchCMSError, memoryCache, mergeHooks, mergeLoggers, noopDocOps, noopImgOps, sha256Hex, withRetry };
|
|
944
1057
|
|
|
945
1058
|
//# sourceMappingURL=index.mjs.map
|