@mandujs/core 0.29.1 → 0.31.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.
@@ -12,6 +12,14 @@ import { ContractValidator, type ContractValidatorOptions } from "../contract/va
12
12
  import { getCookieCodec } from "./cookie-codec";
13
13
  import { type FillingDeps, globalDeps } from "./deps";
14
14
  import { createSSEConnection, type SSEOptions, type SSEConnection } from "./sse";
15
+ import {
16
+ getActiveSpan,
17
+ getTracer,
18
+ runWithSpan,
19
+ type Span,
20
+ type SpanAttributes,
21
+ type SpanOptions,
22
+ } from "../observability/tracing";
15
23
 
16
24
  type ContractInput<
17
25
  TContract extends ContractSchema,
@@ -285,12 +293,45 @@ async function hmacSign(data: string, secret: string): Promise<string> {
285
293
 
286
294
  // ========== ManduContext ==========
287
295
 
296
+ /**
297
+ * Phase 18.ζ — 로더가 캐시 메타데이터를 선언하기 위한 플루언트 헬퍼.
298
+ *
299
+ * 내부적으로는 `ctx._cacheMeta` 에 누적하며, 서버 런타임이 loader 실행
300
+ * 직후 읽어 `_cache` 블록과 동일하게 해석한다. 반환 데이터에 `_cache` 를
301
+ * 끼워넣는 방식과 동일한 의미지만, 타입 안정성이 더 높다.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * .loader(async (ctx) => {
306
+ * ctx.cache.tag("posts").tag("posts:42").maxAge(3600).swr(86400);
307
+ * return { post: await db.getPost(42) };
308
+ * })
309
+ * ```
310
+ */
311
+ export interface CacheHelper {
312
+ tag(...tags: string[]): CacheHelper;
313
+ maxAge(seconds: number): CacheHelper;
314
+ /** alias for maxAge — Next.js parity */
315
+ revalidate(seconds: number): CacheHelper;
316
+ swr(seconds: number): CacheHelper;
317
+ /** alias for swr */
318
+ staleWhileRevalidate(seconds: number): CacheHelper;
319
+ /** 현재까지 누적된 메타데이터 스냅샷 (런타임 내부용) */
320
+ readonly meta: {
321
+ tags: string[];
322
+ maxAge?: number;
323
+ staleWhileRevalidate?: number;
324
+ };
325
+ }
326
+
288
327
  export class ManduContext {
289
328
  private store: Map<string, unknown> = new Map();
290
329
  private _params: Record<string, string>;
291
330
  private _query: Record<string, string>;
292
331
  private _cookies: CookieManager;
293
332
  private _deps: FillingDeps;
333
+ private _cacheMeta: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } = { tags: [] };
334
+ private _cacheHelper: CacheHelper | null = null;
294
335
 
295
336
  constructor(
296
337
  public readonly request: Request,
@@ -303,6 +344,59 @@ export class ManduContext {
303
344
  this._deps = deps ?? globalDeps.get();
304
345
  }
305
346
 
347
+ /**
348
+ * Phase 18.ζ — 로더 내부에서 호출하는 캐시 메타데이터 플루언트 헬퍼.
349
+ * 누적 호출 가능: `ctx.cache.tag("a", "b").maxAge(60).swr(300)`
350
+ */
351
+ get cache(): CacheHelper {
352
+ if (this._cacheHelper) return this._cacheHelper;
353
+ const meta = this._cacheMeta;
354
+ const helper: CacheHelper = {
355
+ tag: (...tags: string[]) => {
356
+ for (const t of tags) {
357
+ if (typeof t === "string" && t.length > 0 && !meta.tags.includes(t)) {
358
+ meta.tags.push(t);
359
+ }
360
+ }
361
+ return helper;
362
+ },
363
+ maxAge: (seconds: number) => {
364
+ if (Number.isFinite(seconds) && seconds >= 0) meta.maxAge = seconds;
365
+ return helper;
366
+ },
367
+ revalidate: (seconds: number) => helper.maxAge(seconds),
368
+ swr: (seconds: number) => {
369
+ if (Number.isFinite(seconds) && seconds >= 0) meta.staleWhileRevalidate = seconds;
370
+ return helper;
371
+ },
372
+ staleWhileRevalidate: (seconds: number) => helper.swr(seconds),
373
+ get meta() {
374
+ return {
375
+ tags: [...meta.tags],
376
+ maxAge: meta.maxAge,
377
+ staleWhileRevalidate: meta.staleWhileRevalidate,
378
+ };
379
+ },
380
+ };
381
+ this._cacheHelper = helper;
382
+ return helper;
383
+ }
384
+
385
+ /**
386
+ * Phase 18.ζ — 서버 런타임이 loader 종료 후 누적된 메타 스냅샷을 읽는다.
387
+ * 내부 전용. 유저 코드는 `ctx.cache` 를 사용할 것.
388
+ */
389
+ getCacheMetaSnapshot(): { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null {
390
+ if (this._cacheMeta.tags.length === 0 && this._cacheMeta.maxAge === undefined && this._cacheMeta.staleWhileRevalidate === undefined) {
391
+ return null;
392
+ }
393
+ return {
394
+ tags: [...this._cacheMeta.tags],
395
+ maxAge: this._cacheMeta.maxAge,
396
+ staleWhileRevalidate: this._cacheMeta.staleWhileRevalidate,
397
+ };
398
+ }
399
+
306
400
  /**
307
401
  * DNA-002: 의존성 접근
308
402
  *
@@ -631,6 +725,78 @@ export class ManduContext {
631
725
  has(key: string): boolean {
632
726
  return this.store.has(key);
633
727
  }
728
+
729
+ // ============================================
730
+ // 🥟 Tracing (Phase 18.θ)
731
+ // ============================================
732
+
733
+ /**
734
+ * The currently-active tracing span for this request, or `undefined`
735
+ * when tracing is disabled. Looked up via AsyncLocalStorage so it
736
+ * stays accurate across nested awaits — a loader that opens a child
737
+ * span before hitting an external API will see `ctx.span` return
738
+ * that child span until the child's `end()` fires.
739
+ *
740
+ * When tracing is disabled this reads as `undefined` (the runtime
741
+ * never opens a recording span), keeping the hot path free of
742
+ * extra allocations.
743
+ */
744
+ get span(): Span | undefined {
745
+ const active = getActiveSpan();
746
+ return active && active.recording ? active : undefined;
747
+ }
748
+
749
+ /**
750
+ * Open a child span scoped to the async context of `fn`. The span
751
+ * auto-ends when the returned promise resolves; if `fn` throws, the
752
+ * span is marked `status=error` with the thrown message, the span
753
+ * is ended, and the error re-thrown.
754
+ *
755
+ * When tracing is disabled this degenerates to just `await fn(noopSpan)`
756
+ * with no allocation in the hot path — callers don't have to branch
757
+ * on `ctx.span` themselves.
758
+ *
759
+ * @example
760
+ * const users = await ctx.startSpan("db.users.findAll", async (span) => {
761
+ * span.setAttribute("db.system", "postgres");
762
+ * return await sql\`SELECT * FROM users\`;
763
+ * });
764
+ */
765
+ async startSpan<T>(
766
+ name: string,
767
+ fn: (span: Span) => Promise<T> | T,
768
+ opts: SpanOptions = {}
769
+ ): Promise<T> {
770
+ const tracer = getTracer();
771
+ if (!tracer.enabled) {
772
+ // Fast path: invoke fn with a shared no-op span (type-shape
773
+ // identical, zero allocations).
774
+ const { startSpan } = tracer;
775
+ const span = startSpan.call(tracer, name, opts);
776
+ return await fn(span);
777
+ }
778
+ const span = tracer.startSpan(name, opts);
779
+ try {
780
+ const result = await runWithSpan(span, () => fn(span));
781
+ if (span.status === "unset") span.setStatus("ok");
782
+ return result;
783
+ } catch (err) {
784
+ const msg = err instanceof Error ? err.message : String(err);
785
+ span.setStatus("error", msg);
786
+ throw err;
787
+ } finally {
788
+ span.end();
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Set attributes on the active request span (if any). Convenience
794
+ * wrapper so user code doesn't have to null-check `ctx.span`
795
+ * themselves.
796
+ */
797
+ setSpanAttributes(attrs: SpanAttributes): void {
798
+ this.span?.setAttributes(attrs);
799
+ }
634
800
  }
635
801
 
636
802
  /** Route context for error reporting */
@@ -72,8 +72,17 @@ export interface LoaderOptions<T = unknown> {
72
72
 
73
73
  /** Loader 캐시/ISR 옵션 */
74
74
  export interface LoaderCacheOptions {
75
- /** 캐시 유지 시간 (초). 0이면 캐시 안 함, Infinity면 영구 */
75
+ /**
76
+ * Fresh 캐시 유지 시간 (초). `maxAge` 의 별칭 — Next.js `revalidate`
77
+ * API 와 호환성을 유지한다. 0 이면 캐시 안 함.
78
+ */
76
79
  revalidate?: number;
80
+ /**
81
+ * Phase 18.ζ — fresh 창을 지난 뒤 캐시를 계속 서빙할 stale-while-revalidate
82
+ * 창 길이 (초). 지정 시 `revalidate` 구간 후 이 기간만큼 STALE 응답을
83
+ * 즉시 반환하고 백그라운드에서 재생성한다.
84
+ */
85
+ staleWhileRevalidate?: number;
77
86
  /** 온디맨드 무효화 태그 */
78
87
  tags?: string[];
79
88
  }
package/src/index.ts CHANGED
@@ -38,6 +38,38 @@ export { type GuardViolation } from "./guard";
38
38
  export { type Severity } from "./guard";
39
39
  export { Image, type ImageProps } from "./components/Image";
40
40
 
41
+ // Phase 18.θ — `Tracer` is exported by both `runtime/trace.ts` (the
42
+ // legacy lifecycle trace collector) and `observability/tracing.ts` (the
43
+ // new OTel tracer). `export *` from both yields TS2308 ambiguity, so
44
+ // explicitly re-export the new one as canonical. The legacy runtime
45
+ // tracer remains accessible via the `runtime/trace` subpath export
46
+ // (`createTracer`, `TraceEvent`, etc. are unchanged).
47
+ export {
48
+ Tracer,
49
+ ConsoleSpanExporter,
50
+ OtlpHttpSpanExporter,
51
+ encodeOtlpJson,
52
+ parseTraceparent,
53
+ formatTraceparent,
54
+ newTraceId,
55
+ newSpanId,
56
+ getActiveSpan,
57
+ runWithSpan,
58
+ injectTraceContext,
59
+ getTracer,
60
+ setTracer,
61
+ resetTracer,
62
+ createTracerFromConfig,
63
+ type Span,
64
+ type SpanAttributes,
65
+ type SpanExporter,
66
+ type SpanKind,
67
+ type SpanOptions,
68
+ type SpanStatus,
69
+ type TraceparentFields,
70
+ type TracerConfig,
71
+ } from "./observability/tracing";
72
+
41
73
  // Consolidated Mandu namespace
42
74
  import { ManduFilling, ManduContext, ManduFillingFactory, createSSEConnection } from "./filling";
43
75
  import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract, querySchema, bodySchema, apiError } from "./contract";
@@ -29,6 +29,13 @@ export {
29
29
  rateLimitMiddleware,
30
30
  } from "./bridge";
31
31
 
32
+ export {
33
+ schedulerCron,
34
+ setActiveSchedulerRegistration,
35
+ getActiveSchedulerRegistration,
36
+ type SchedulerCronMiddlewareOptions,
37
+ } from "./scheduler-cron";
38
+
32
39
  export { cors, type CorsMiddlewareOptions } from "./cors";
33
40
  export { jwt, type JwtMiddlewareOptions } from "./jwt";
34
41
  export { csrf, type CsrfMiddlewareOptions } from "./csrf";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `scheduler-cron` — request-level middleware that exposes the running
3
+ * scheduler registration on `ctx` so handlers can inspect job status or
4
+ * trigger an ad-hoc tick for debugging.
5
+ *
6
+ * This is intentionally a thin bridge — the cron jobs themselves are
7
+ * defined with {@link import("../scheduler").defineCron} and started at
8
+ * `startServer()` boot time. The middleware does NOT start or stop the
9
+ * scheduler; its only job is to make the `CronRegistration` handle
10
+ * available to downstream request handlers (e.g., an observability
11
+ * dashboard API that wants to render `status()`).
12
+ *
13
+ * The middleware is opt-in — it's NOT part of the default chain. Add it
14
+ * to `mandu.config.ts` `middleware: [...]` only if you have a route that
15
+ * needs `ctx.scheduler`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * // mandu.config.ts
20
+ * import { defineConfig } from "@mandujs/core";
21
+ * import { schedulerCron } from "@mandujs/core/middleware";
22
+ * import { jobs } from "./jobs";
23
+ *
24
+ * export default defineConfig({
25
+ * scheduler: { jobs },
26
+ * middleware: [schedulerCron()],
27
+ * });
28
+ * ```
29
+ */
30
+
31
+ import { defineMiddleware } from "./define";
32
+ import type { Middleware } from "./define";
33
+ import type { CronRegistration } from "../scheduler";
34
+
35
+ /**
36
+ * Global handle to the active {@link CronRegistration}, set by
37
+ * `startServer()` when it boots the scheduler. `scheduler-cron` middleware
38
+ * reads this slot on every request so the registration is always the one
39
+ * actually running.
40
+ *
41
+ * We store it on `globalThis` rather than module-scope so that multiple
42
+ * bundles (e.g., `@mandujs/core` loaded twice in a monorepo hot-reload)
43
+ * still see the same handle — matches the registry pattern in
44
+ * `runtime/server.ts`.
45
+ */
46
+ const GLOBAL_KEY = "__MANDU_SCHEDULER_REGISTRATION__";
47
+
48
+ interface SchedulerGlobal {
49
+ [GLOBAL_KEY]?: CronRegistration | null;
50
+ }
51
+
52
+ export function setActiveSchedulerRegistration(reg: CronRegistration | null): void {
53
+ (globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] = reg;
54
+ }
55
+
56
+ export function getActiveSchedulerRegistration(): CronRegistration | null {
57
+ return (globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] ?? null;
58
+ }
59
+
60
+ export interface SchedulerCronMiddlewareOptions {
61
+ /**
62
+ * Custom header to stamp on the response with the current scheduler job
63
+ * count. Useful for smoke-checking that the scheduler is running in a
64
+ * given environment. Default: no header is added.
65
+ */
66
+ statusHeader?: string;
67
+ }
68
+
69
+ /**
70
+ * Creates a middleware that exposes the scheduler registration via a response
71
+ * header and (optionally) stamps a status header. The registration itself is
72
+ * wired into `ctx` via the request-level composition chain — downstream
73
+ * code reads it with {@link getActiveSchedulerRegistration}.
74
+ */
75
+ export function schedulerCron(options: SchedulerCronMiddlewareOptions = {}): Middleware {
76
+ return defineMiddleware({
77
+ name: "scheduler-cron",
78
+ async handler(_req, next) {
79
+ const response = await next();
80
+ const reg = getActiveSchedulerRegistration();
81
+ if (reg && options.statusHeader) {
82
+ const status = reg.status();
83
+ const jobCount = Object.keys(status).length;
84
+ // Clone headers to avoid mutating an immutable response body.
85
+ const headers = new Headers(response.headers);
86
+ headers.set(options.statusHeader, String(jobCount));
87
+ return new Response(response.body, {
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ headers,
91
+ });
92
+ }
93
+ return response;
94
+ },
95
+ });
96
+ }
@@ -35,3 +35,29 @@ export {
35
35
  type HeapSnapshot,
36
36
  type CacheName,
37
37
  } from "./metrics";
38
+ // Phase 18.θ: OpenTelemetry-compatible request tracing
39
+ export {
40
+ Tracer,
41
+ ConsoleSpanExporter,
42
+ OtlpHttpSpanExporter,
43
+ encodeOtlpJson,
44
+ parseTraceparent,
45
+ formatTraceparent,
46
+ newTraceId,
47
+ newSpanId,
48
+ getActiveSpan,
49
+ runWithSpan,
50
+ injectTraceContext,
51
+ getTracer,
52
+ setTracer,
53
+ resetTracer,
54
+ createTracerFromConfig,
55
+ type Span,
56
+ type SpanAttributes,
57
+ type SpanExporter,
58
+ type SpanKind,
59
+ type SpanOptions,
60
+ type SpanStatus,
61
+ type TraceparentFields,
62
+ type TracerConfig,
63
+ } from "./tracing";