@mandujs/core 0.30.0 → 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.
@@ -84,6 +84,11 @@ import {
84
84
  type ComposedHandler,
85
85
  } from "../middleware/compose";
86
86
  import type { Middleware } from "../middleware/define";
87
+ // Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
88
+ // synchronous; the cost of unused code is trivial — `defineCron` is a thin
89
+ // wrapper around `Bun.cron`).
90
+ import { defineCron as schedulerDefineCron } from "../scheduler";
91
+ import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
87
92
  import { createFetchHandler } from "./handler";
88
93
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
89
94
  import { handleImageRequest } from "./image-handler";
@@ -91,6 +96,14 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
91
96
  import { isRedirectResponse } from "./redirect";
92
97
  import { isNotFoundResponse } from "./not-found";
93
98
  import { newId } from "../id";
99
+ // Phase 18.κ — typed RPC dispatch (tRPC-like). See
100
+ // `packages/core/src/contract/rpc.ts` + `docs/architect/typed-rpc.md`.
101
+ import {
102
+ matchRpcPath,
103
+ dispatchRpc,
104
+ registerRpc,
105
+ clearRpcRegistry,
106
+ } from "../contract/rpc";
94
107
  import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
95
108
  import {
96
109
  DEFAULT_PRERENDER_DIR,
@@ -504,6 +517,37 @@ export interface ServerOptions {
504
517
  * `secureMiddleware`, `rateLimitMiddleware`).
505
518
  */
506
519
  middleware?: Middleware[];
520
+ /**
521
+ * Phase 18.κ — tRPC-like typed RPC endpoints.
522
+ *
523
+ * Keys map to `/api/rpc/<name>/<method>` routes. Each value is a
524
+ * `defineRpc()` result (see `@mandujs/core/contract/rpc`). Populating
525
+ * this field at `startServer()` time registers every endpoint with
526
+ * the global RPC registry; the dispatcher runs BEFORE β's route
527
+ * matcher so RPC routes never collide with file-system API routes.
528
+ *
529
+ * Typically threaded from `ManduConfig.rpc.endpoints`.
530
+ */
531
+ rpc?: {
532
+ endpoints?: Record<string, import("../contract/rpc").RpcDefinition<import("../contract/rpc").RpcProcedureRecord>>;
533
+ };
534
+ /**
535
+ * Phase 18.λ — declarative cron scheduler.
536
+ *
537
+ * When `jobs` is non-empty and `disabled !== true`, `startServer()`
538
+ * instantiates a `CronRegistration` via `defineCron(jobs)`, calls
539
+ * `.start()` after the HTTP listener is bound, and wires the handle
540
+ * into `stop()` so the returned `ManduServer.stop()` also drains any
541
+ * in-flight cron tick before returning. Jobs whose `runOn` omits
542
+ * `"bun"` are registered but never fire on the local Bun host — they
543
+ * still appear in `status()` so dashboards can render their existence.
544
+ *
545
+ * Typically threaded from `ManduConfig.scheduler`.
546
+ */
547
+ scheduler?: {
548
+ jobs?: import("../scheduler").CronDef[];
549
+ disabled?: boolean;
550
+ };
507
551
  }
508
552
 
509
553
  export interface ManduServer {
@@ -3325,6 +3369,47 @@ async function handleRequestInternal(
3325
3369
  }
3326
3370
  // ─── End Phase 18.ε ──────────────────────────────────────────────────────
3327
3371
 
3372
+ // ─── Phase 18.κ — typed RPC dispatch ──────────────────────────────────────
3373
+ // Runs AFTER γ's prerendered pass-through (handled earlier at step 0.5),
3374
+ // AFTER ζ's ISR/SWR cache check (per-route, inside handlePageRoute), and
3375
+ // BEFORE β's file-system route dispatch below. The canonical URL shape is
3376
+ //
3377
+ // POST /api/rpc/<endpoint>/<method>
3378
+ //
3379
+ // with JSON body `{ input: <value> }`. The dispatcher:
3380
+ // 1. matches `/api/rpc/<name>/<method>` via `matchRpcPath()` (returns
3381
+ // `null` for any other path — then we fall through to β),
3382
+ // 2. looks up the registered `RpcDefinition` in the module-level
3383
+ // `rpcRegistry` (populated by `registerRpc` at boot from
3384
+ // `ServerOptions.rpc.endpoints`),
3385
+ // 3. validates the request body against `procedure.input` (Zod),
3386
+ // invokes `procedure.handler`, validates the return value against
3387
+ // `procedure.output` (Zod), and ships a
3388
+ // `{ ok: true, data } | { ok: false, error }` JSON envelope.
3389
+ //
3390
+ // All failure paths return structured envelopes — never throws — so the
3391
+ // outer request handler's 5xx catch is unreachable on the happy path.
3392
+ // See `packages/core/src/contract/rpc.ts` and
3393
+ // `docs/architect/typed-rpc.md`.
3394
+ {
3395
+ const rpcMatch = matchRpcPath(pathname);
3396
+ if (rpcMatch) {
3397
+ const rpcResponse = await dispatchRpc(
3398
+ req,
3399
+ rpcMatch.endpoint,
3400
+ rpcMatch.method,
3401
+ { isDev: settings.isDev }
3402
+ );
3403
+ if (settings.cors && isCorsRequest(req)) {
3404
+ const corsOptions: CorsOptions =
3405
+ typeof settings.cors === "object" ? settings.cors : {};
3406
+ return ok(applyCorsToResponse(rpcResponse, req, corsOptions));
3407
+ }
3408
+ return ok(rpcResponse);
3409
+ }
3410
+ }
3411
+ // ─── End Phase 18.κ ───────────────────────────────────────────────────────
3412
+
3328
3413
  // 3. 라우트 매칭
3329
3414
  const match = router.match(pathname);
3330
3415
  if (!match) {
@@ -3523,6 +3608,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3523
3608
  observability: observabilityOption,
3524
3609
  prerender: prerenderOption,
3525
3610
  middleware: middlewareOption,
3611
+ rpc: rpcOption,
3612
+ scheduler: schedulerOption,
3526
3613
  } = options;
3527
3614
 
3528
3615
  // Phase 18.ε — build the request-level middleware chain once at boot.
@@ -3609,6 +3696,22 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3609
3696
 
3610
3697
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
3611
3698
 
3699
+ // ─── Phase 18.κ — register RPC endpoints from options ──────────────────
3700
+ // The RPC registry is module-scoped (shared across all server
3701
+ // instances in this process). Clearing first keeps repeated
3702
+ // `startServer()` calls in tests deterministic — otherwise a stale
3703
+ // endpoint from a prior run could answer a later instance's requests.
3704
+ //
3705
+ // This runs once at boot; HMR-time re-registration goes through the
3706
+ // exported `registerRpc()` from `@mandujs/core/contract/rpc`.
3707
+ clearRpcRegistry();
3708
+ if (rpcOption?.endpoints) {
3709
+ for (const [name, definition] of Object.entries(rpcOption.endpoints)) {
3710
+ registerRpc(name, definition);
3711
+ }
3712
+ }
3713
+ // ─── End Phase 18.κ ────────────────────────────────────────────────────
3714
+
3612
3715
  // ─── Phase 18.ζ — ISR/SWR 캐시 초기화 ──────────────────────────────────
3613
3716
  // `cacheOption` 는 `true` | `false` | `CacheStore` | `CacheConfig` 를 받는다:
3614
3717
  // - `true` → MemoryCacheStore(1000) 를 기본값으로 생성.
@@ -3788,12 +3891,64 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3788
3891
  }
3789
3892
  }
3790
3893
 
3894
+ // ─── Phase 18.λ — declarative cron scheduler ────────────────────────────
3895
+ // Boot the scheduler AFTER the HTTP listener is live so a malformed cron
3896
+ // expression (caught by validateCronExpression) surfaces alongside the
3897
+ // other boot errors rather than aborting the server. `startServer` is
3898
+ // synchronous by contract, so we use the already-imported scheduler
3899
+ // module rather than `await import`.
3900
+ let schedulerRegistration: import("../scheduler").CronRegistration | null = null;
3901
+ const jobDefs = schedulerOption?.jobs ?? [];
3902
+ const schedulerDisabled = schedulerOption?.disabled === true;
3903
+ if (jobDefs.length > 0 && !schedulerDisabled) {
3904
+ try {
3905
+ schedulerRegistration = schedulerDefineCron(jobDefs);
3906
+ schedulerRegistration.start();
3907
+ setActiveSchedulerRegistration(schedulerRegistration);
3908
+ const bunJobCount = Object.keys(schedulerRegistration.status()).filter(
3909
+ (name) => {
3910
+ const def = jobDefs.find((j) => j.name === name);
3911
+ const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
3912
+ return runOn.includes("bun");
3913
+ },
3914
+ ).length;
3915
+ console.log(
3916
+ `⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
3917
+ (jobDefs.length !== bunJobCount
3918
+ ? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
3919
+ : ""),
3920
+ );
3921
+ } catch (err) {
3922
+ // Scheduler failures MUST NOT crash the server — a bad cron string is
3923
+ // a developer error, but the HTTP surface should keep serving. Log
3924
+ // loudly and leave the registration null.
3925
+ console.error(
3926
+ "❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
3927
+ err instanceof Error ? err.message : err,
3928
+ );
3929
+ }
3930
+ }
3931
+
3791
3932
  return {
3792
3933
  server,
3793
3934
  router,
3794
3935
  registry,
3795
3936
  stop: () => {
3796
3937
  registry.kitchen?.stop();
3938
+ // Fire-and-forget the async scheduler drain so `stop()` stays
3939
+ // synchronous for backwards compatibility with existing consumers.
3940
+ // Tests that need to await drain can reach for `registration.stop()`
3941
+ // directly; `server.stop()` triggers shutdown but doesn't block on
3942
+ // in-flight cron handler completion here.
3943
+ if (schedulerRegistration) {
3944
+ const reg = schedulerRegistration;
3945
+ schedulerRegistration = null;
3946
+ void reg.stop()
3947
+ .then(() => setActiveSchedulerRegistration(null))
3948
+ .catch((err) => {
3949
+ console.error("[scheduler] shutdown error:", err);
3950
+ });
3951
+ }
3797
3952
  server.stop();
3798
3953
  },
3799
3954
  };