@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.
- package/package.json +4 -1
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +49 -0
- package/src/config/validate.ts +48 -0
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/runtime/server.ts +155 -0
- package/src/scheduler/index.ts +547 -343
- package/src/scheduler/validate.ts +169 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
};
|