@spfn/core 0.3.0-beta.3 → 0.3.0-beta.30
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/README.md +301 -10
- package/dist/app-config/index.d.ts +39 -1
- package/dist/app-config/index.js.map +1 -1
- package/dist/authz/index.js +47 -391
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +157 -16
- package/dist/codegen/index.js +772 -166
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +4 -4
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +39 -16
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +230 -35
- package/dist/db/index.js +316 -160
- package/dist/db/index.js.map +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-CIZlYJlL.d.ts +157 -0
- package/dist/errors/index.d.ts +18 -1
- package/dist/errors/index.js +47 -391
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +202 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +94 -18
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +18 -5
- package/dist/event/sse/index.js +452 -114
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +52 -397
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +75 -14
- package/dist/nextjs/index.js +200 -84
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +46 -8
- package/dist/nextjs/server.js +15 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -42
- package/dist/ops/index.js +648 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +5 -2
- package/dist/route/index.js +107 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
- package/dist/router-C57y-vz1.d.ts +184 -0
- package/dist/router-CX5uVjmU.d.ts +406 -0
- package/dist/server/index.d.ts +130 -7
- package/dist/server/index.js +1174 -164
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +206 -0
- package/dist/state/client.js +678 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +443 -0
- package/dist/state/index.js +644 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +49 -0
- package/dist/state/react.js +50 -0
- package/dist/state/react.js.map +1 -0
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1013 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/types-CKH0D3Dy.d.ts +441 -0
- package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
- package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +39 -9
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +721 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +442 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +845 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +445 -0
- package/src/event/README.md +994 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +756 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/src/state/README.md +806 -0
- package/dist/token-manager-BT5EnUAR.d.ts +0 -278
- package/dist/types-9oszaJqp.d.ts +0 -250
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { env } from '@spfn/core/config';
|
|
1
|
+
import { env, registry } from '@spfn/core/config';
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { resolve, join } from 'path';
|
|
4
4
|
import { parse } from 'dotenv';
|
|
@@ -13,6 +13,7 @@ import { randomBytes, createHash } from 'crypto';
|
|
|
13
13
|
import { Agent, setGlobalDispatcher } from 'undici';
|
|
14
14
|
import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, closeDatabase, migrationTargets } from '@spfn/core/db';
|
|
15
15
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
16
|
+
import { FormatRegistry, Type } from '@sinclair/typebox';
|
|
16
17
|
import { serve } from '@hono/node-server';
|
|
17
18
|
import PgBoss from 'pg-boss';
|
|
18
19
|
import { networkInterfaces } from 'os';
|
|
@@ -354,9 +355,10 @@ function parseEnvFile(filePath) {
|
|
|
354
355
|
return parse(readFileSync(filePath, "utf-8"));
|
|
355
356
|
}
|
|
356
357
|
function loadEnv(options = {}) {
|
|
358
|
+
const nodeEnvAtEntry = process.env.NODE_ENV;
|
|
357
359
|
const {
|
|
358
360
|
cwd = process.cwd(),
|
|
359
|
-
nodeEnv =
|
|
361
|
+
nodeEnv = nodeEnvAtEntry || "local",
|
|
360
362
|
server = true,
|
|
361
363
|
debug = false,
|
|
362
364
|
override = false
|
|
@@ -386,8 +388,30 @@ function loadEnv(options = {}) {
|
|
|
386
388
|
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
387
389
|
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
388
390
|
}
|
|
391
|
+
warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
|
|
389
392
|
return { loadedFiles, loadedKeys };
|
|
390
393
|
}
|
|
394
|
+
function warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, explicitNodeEnv, resolvedNodeEnv) {
|
|
395
|
+
if (nodeEnvAtEntry || explicitNodeEnv) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const declared = process.env.NODE_ENV;
|
|
399
|
+
if (!declared) {
|
|
400
|
+
envLogger.warn(
|
|
401
|
+
`NODE_ENV is not set anywhere; env files were resolved for "${resolvedNodeEnv}". Set NODE_ENV in the process environment to choose the file set.`
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (declared === resolvedNodeEnv) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!existsSync(resolve(cwd, `.env.${declared}`))) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
envLogger.warn(
|
|
412
|
+
`.env.${declared} was NOT loaded: NODE_ENV was not set when the process started, so env files were resolved for "${resolvedNodeEnv}" before a file declared NODE_ENV=${declared}. Set NODE_ENV in the process environment, not in a .env file.`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
391
415
|
|
|
392
416
|
// src/server/dotenv-loader.ts
|
|
393
417
|
var warned = false;
|
|
@@ -405,7 +429,15 @@ function loadEnvFiles() {
|
|
|
405
429
|
function createBoundedWriter(stream, maxQueue, onClose) {
|
|
406
430
|
const pending = [];
|
|
407
431
|
let flushing = false;
|
|
432
|
+
let reserved = 0;
|
|
408
433
|
let closed = false;
|
|
434
|
+
const overflows = () => {
|
|
435
|
+
if (pending.length + reserved <= maxQueue) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
onClose("outbound queue overflow (slow consumer)");
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
409
441
|
const flush = async () => {
|
|
410
442
|
if (flushing || closed) {
|
|
411
443
|
return;
|
|
@@ -429,11 +461,19 @@ function createBoundedWriter(stream, maxQueue, onClose) {
|
|
|
429
461
|
return;
|
|
430
462
|
}
|
|
431
463
|
pending.push(frame);
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
464
|
+
if (!overflows()) {
|
|
465
|
+
void flush();
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
reserve() {
|
|
469
|
+
if (closed) {
|
|
470
|
+
return false;
|
|
435
471
|
}
|
|
436
|
-
|
|
472
|
+
reserved++;
|
|
473
|
+
return !overflows();
|
|
474
|
+
},
|
|
475
|
+
clearReserved() {
|
|
476
|
+
reserved = 0;
|
|
437
477
|
},
|
|
438
478
|
close() {
|
|
439
479
|
closed = true;
|
|
@@ -444,6 +484,124 @@ function createBoundedWriter(stream, maxQueue, onClose) {
|
|
|
444
484
|
}
|
|
445
485
|
};
|
|
446
486
|
}
|
|
487
|
+
var registryLogger = logger.child("@spfn/core:sse");
|
|
488
|
+
var SUBSCRIPTION_GRANTED = "subscription.granted";
|
|
489
|
+
var SUBSCRIPTION_REVOKED = "subscription.revoked";
|
|
490
|
+
function createConnectionRegistry() {
|
|
491
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
492
|
+
const pendingBySubject = /* @__PURE__ */ new Map();
|
|
493
|
+
const beginAdmission = (subject, signal) => {
|
|
494
|
+
const changes = [];
|
|
495
|
+
addToSet(pendingBySubject, subject, changes);
|
|
496
|
+
const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
|
|
497
|
+
return {
|
|
498
|
+
cancel: end,
|
|
499
|
+
open: (connection) => {
|
|
500
|
+
if (!end()) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
applyRecorded(connection, changes);
|
|
504
|
+
addToSet(bySubject, subject, connection);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
};
|
|
509
|
+
const change = (kind, subject, eventName, match) => {
|
|
510
|
+
const pending = pendingBySubject.get(subject);
|
|
511
|
+
const connections = bySubject.get(subject);
|
|
512
|
+
pending?.forEach((changes) => changes.push({ kind, eventName, match }));
|
|
513
|
+
connections?.forEach((connection) => {
|
|
514
|
+
if (matchesRequest(match, connection.request)) {
|
|
515
|
+
(kind === "grant" ? grantOn : revokeOn)(connection, eventName);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
if (!pending && !connections) {
|
|
519
|
+
registryLogger.debug("SSE subscription change for a subject with no open connection ignored", {
|
|
520
|
+
event: eventName,
|
|
521
|
+
subject
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
return {
|
|
526
|
+
beginAdmission,
|
|
527
|
+
remove: (connection) => {
|
|
528
|
+
if (connection.subject) {
|
|
529
|
+
removeFromSet(bySubject, connection.subject, connection);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
/** Open connections only — a pending admission is not counted. */
|
|
533
|
+
count: (subject) => bySubject.get(subject)?.size ?? 0,
|
|
534
|
+
pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
|
|
535
|
+
grant: (subject, eventName, match) => change("grant", subject, eventName, match),
|
|
536
|
+
revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function endOnce(signal, remove) {
|
|
540
|
+
let ended = false;
|
|
541
|
+
const end = () => {
|
|
542
|
+
if (ended) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
ended = true;
|
|
546
|
+
signal.removeEventListener("abort", end);
|
|
547
|
+
remove();
|
|
548
|
+
return true;
|
|
549
|
+
};
|
|
550
|
+
signal.addEventListener("abort", end, { once: true });
|
|
551
|
+
if (signal.aborted) {
|
|
552
|
+
end();
|
|
553
|
+
}
|
|
554
|
+
return end;
|
|
555
|
+
}
|
|
556
|
+
function addToSet(map, key, value) {
|
|
557
|
+
const set = map.get(key) ?? /* @__PURE__ */ new Set();
|
|
558
|
+
set.add(value);
|
|
559
|
+
map.set(key, set);
|
|
560
|
+
}
|
|
561
|
+
function removeFromSet(map, key, value) {
|
|
562
|
+
const set = map.get(key);
|
|
563
|
+
set?.delete(value);
|
|
564
|
+
if (set?.size === 0) {
|
|
565
|
+
map.delete(key);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function matchesRequest(match, request) {
|
|
569
|
+
return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
|
|
570
|
+
}
|
|
571
|
+
function applyRecorded(connection, changes) {
|
|
572
|
+
changes.forEach(({ kind, eventName, match }) => {
|
|
573
|
+
if (!matchesRequest(match, connection.request)) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
|
|
577
|
+
if (from.delete(eventName)) {
|
|
578
|
+
to.add(eventName);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
function grantOn(connection, eventName) {
|
|
583
|
+
if (!connection.denied.has(eventName)) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
connection.unsubscribes.set(eventName, connection.subscribe(eventName));
|
|
587
|
+
connection.denied.delete(eventName);
|
|
588
|
+
connection.allowed.add(eventName);
|
|
589
|
+
connection.notify(SUBSCRIPTION_GRANTED, eventName);
|
|
590
|
+
}
|
|
591
|
+
function revokeOn(connection, eventName) {
|
|
592
|
+
if (!connection.allowed.has(eventName)) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
connection.unsubscribes.get(eventName)?.();
|
|
596
|
+
connection.unsubscribes.delete(eventName);
|
|
597
|
+
connection.allowed.delete(eventName);
|
|
598
|
+
connection.denied.add(eventName);
|
|
599
|
+
connection.notify(SUBSCRIPTION_REVOKED, eventName);
|
|
600
|
+
}
|
|
601
|
+
function unsubscribeAll(connection) {
|
|
602
|
+
connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
603
|
+
connection.unsubscribes.clear();
|
|
604
|
+
}
|
|
447
605
|
|
|
448
606
|
// src/event/sse/handler.ts
|
|
449
607
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
@@ -474,125 +632,329 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
474
632
|
auth: authConfig,
|
|
475
633
|
maxQueue = DEFAULT_MAX_QUEUE
|
|
476
634
|
} = config;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
635
|
+
const registry = createConnectionRegistry();
|
|
636
|
+
registerSubscriptionControls(router, registry);
|
|
637
|
+
const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
|
|
638
|
+
const handler = async (c) => {
|
|
639
|
+
const admission = await admitConnection(c, streamConfig, tokenManager);
|
|
640
|
+
if (admission instanceof Response) {
|
|
641
|
+
return admission;
|
|
481
642
|
}
|
|
482
|
-
if (
|
|
483
|
-
|
|
643
|
+
if (c.req.raw.signal.aborted) {
|
|
644
|
+
admission.pending?.cancel();
|
|
645
|
+
return new Response(null, { status: 499 });
|
|
484
646
|
}
|
|
485
|
-
|
|
486
|
-
|
|
647
|
+
c.header("X-Accel-Buffering", "no");
|
|
648
|
+
return streamSSE(c, (stream) => runStream(c, stream, admission, streamConfig), async (err) => {
|
|
649
|
+
sseLogger.error("SSE stream error", {
|
|
650
|
+
error: err.message
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
return Object.assign(handler, {
|
|
655
|
+
/** @internal Open registered connections of a subject (tests). */
|
|
656
|
+
_openConnections: (subject) => registry.count(subject),
|
|
657
|
+
/** @internal Connects of a subject still in admission (tests). */
|
|
658
|
+
_pendingAdmissions: (subject) => registry.pendingCount(subject)
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
function registerSubscriptionControls(router, registry) {
|
|
662
|
+
for (const eventName of router.eventNames) {
|
|
663
|
+
router.events[eventName]._registerSubscriptionControl({
|
|
664
|
+
grant: (subject, match) => registry.grant(subject, eventName, match),
|
|
665
|
+
revoke: (subject, match) => registry.revoke(subject, eventName, match)
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async function admitConnection(c, config, tokenManager) {
|
|
670
|
+
const { router, authConfig, registry } = config;
|
|
671
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
672
|
+
if (subject === false) {
|
|
673
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
674
|
+
}
|
|
675
|
+
if (subject === null) {
|
|
676
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
677
|
+
}
|
|
678
|
+
if (subject) {
|
|
679
|
+
c.set("sseSubject", subject);
|
|
680
|
+
}
|
|
681
|
+
const requested = parseRequestedEvents(c);
|
|
682
|
+
if (!requested) {
|
|
683
|
+
return c.json({ error: "Missing events parameter" }, 400);
|
|
684
|
+
}
|
|
685
|
+
const validEventNames = router.eventNames;
|
|
686
|
+
const invalidEvents = requested.filter((e) => !validEventNames.includes(e));
|
|
687
|
+
if (invalidEvents.length > 0) {
|
|
688
|
+
return c.json({
|
|
689
|
+
error: "Invalid event names",
|
|
690
|
+
invalidEvents,
|
|
691
|
+
validEvents: validEventNames
|
|
692
|
+
}, 400);
|
|
693
|
+
}
|
|
694
|
+
return whilePending(
|
|
695
|
+
subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
|
|
696
|
+
() => authorizeConnection(c, router, authConfig, subject, requested)
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
async function whilePending(pending, check) {
|
|
700
|
+
let outcome;
|
|
701
|
+
try {
|
|
702
|
+
outcome = await check();
|
|
703
|
+
return outcome instanceof Response ? outcome : { ...outcome, pending };
|
|
704
|
+
} finally {
|
|
705
|
+
if (!outcome || outcome instanceof Response) {
|
|
706
|
+
pending?.cancel();
|
|
487
707
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async function authorizeConnection(c, router, authConfig, subject, requested) {
|
|
711
|
+
const authorized = await authorizeEvents(subject, requested, authConfig);
|
|
712
|
+
if (authorized === null) {
|
|
713
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
714
|
+
}
|
|
715
|
+
const request = { query: c.req.query(), headers: c.req.header() };
|
|
716
|
+
const resolved = await resolveConnectionPrincipal(subject, request, authConfig);
|
|
717
|
+
if (!resolved) {
|
|
718
|
+
return c.json({ error: "Principal could not be resolved" }, 403);
|
|
719
|
+
}
|
|
720
|
+
const allowed = subject ? await checkCanSubscribe(router, authorized, { subject, principal: resolved.principal, request }) : authorized;
|
|
721
|
+
const denied = requested.filter((e) => !allowed.includes(e));
|
|
722
|
+
sseLogger.debug("SSE connection requested", {
|
|
723
|
+
events: allowed,
|
|
724
|
+
deniedEvents: denied,
|
|
725
|
+
subject: subject || void 0,
|
|
726
|
+
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
727
|
+
});
|
|
728
|
+
return { subject, requested, allowed, denied, request, principal: resolved.principal };
|
|
729
|
+
}
|
|
730
|
+
async function resolveConnectionPrincipal(subject, request, authConfig) {
|
|
731
|
+
if (!subject || !authConfig?.resolvePrincipal) {
|
|
732
|
+
return { principal: void 0 };
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
return { principal: await authConfig.resolvePrincipal(subject, request) };
|
|
736
|
+
} catch {
|
|
737
|
+
sseLogger.warn("SSE principal could not be resolved \u2014 connection refused", { subject });
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function checkCanSubscribe(router, events, ctx) {
|
|
742
|
+
const verdicts = await Promise.all(events.map((eventName) => askCanSubscribe(eventName, router.events[eventName], ctx)));
|
|
743
|
+
return events.filter((_, i) => verdicts[i]);
|
|
744
|
+
}
|
|
745
|
+
async function askCanSubscribe(eventName, eventDef, ctx) {
|
|
746
|
+
if (!eventDef) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (!eventDef._canSubscribe) {
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
return await eventDef._canSubscribe(ctx) === true;
|
|
754
|
+
} catch {
|
|
755
|
+
sseLogger.warn("SSE canSubscribe threw \u2014 event denied on this connection", {
|
|
756
|
+
event: eventName,
|
|
757
|
+
subject: ctx.subject
|
|
758
|
+
});
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function runStream(c, stream, admission, config) {
|
|
763
|
+
const { allowed, denied, subject, request } = admission;
|
|
764
|
+
let connectionDead = false;
|
|
765
|
+
let pingTimer;
|
|
766
|
+
let onClosed;
|
|
767
|
+
const cleanup = (reason) => {
|
|
768
|
+
if (connectionDead) return;
|
|
769
|
+
connectionDead = true;
|
|
770
|
+
clearInterval(pingTimer);
|
|
771
|
+
unsubscribeAll(connection);
|
|
772
|
+
config.registry.remove(connection);
|
|
773
|
+
writer.close();
|
|
774
|
+
onClosed?.();
|
|
775
|
+
sseLogger.info("SSE dead connection cleaned up", {
|
|
776
|
+
events: [...connection.allowed],
|
|
777
|
+
reason
|
|
778
|
+
});
|
|
779
|
+
};
|
|
780
|
+
const writer = createBoundedWriter(stream, config.maxQueue, (reason) => {
|
|
781
|
+
sseLogger.warn("SSE connection closed", { events: [...connection.allowed], reason });
|
|
782
|
+
cleanup(reason);
|
|
783
|
+
});
|
|
784
|
+
const hold = createFrameHold(writer);
|
|
785
|
+
const subscriberContext = {
|
|
786
|
+
router: config.router,
|
|
787
|
+
authConfig: config.authConfig,
|
|
788
|
+
subject,
|
|
789
|
+
request,
|
|
790
|
+
writeFrame: createFrameWriter(writer),
|
|
791
|
+
send: hold.send,
|
|
792
|
+
isDead: () => connectionDead
|
|
793
|
+
};
|
|
794
|
+
const connection = {
|
|
795
|
+
subject,
|
|
796
|
+
requested: admission.requested,
|
|
797
|
+
allowed: new Set(allowed),
|
|
798
|
+
denied: new Set(denied),
|
|
799
|
+
unsubscribes: /* @__PURE__ */ new Map(),
|
|
800
|
+
request,
|
|
801
|
+
subscribe: createEventSubscriber(subscriberContext),
|
|
802
|
+
notify: (frame, eventName) => {
|
|
803
|
+
if (connectionDead) return;
|
|
804
|
+
hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
|
|
491
805
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
806
|
+
};
|
|
807
|
+
if (admission.pending?.open(connection) === false) {
|
|
808
|
+
cleanup("aborted during admission");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
void openSubscriptions(connection, writer, hold, {
|
|
812
|
+
...subscriberContext,
|
|
813
|
+
principal: admission.principal
|
|
814
|
+
});
|
|
815
|
+
pingTimer = setInterval(() => {
|
|
816
|
+
if (connectionDead) return;
|
|
817
|
+
hold.send(() => writer.enqueue({
|
|
818
|
+
event: "ping",
|
|
819
|
+
data: JSON.stringify({ timestamp: Date.now() })
|
|
820
|
+
}));
|
|
821
|
+
}, config.pingInterval);
|
|
822
|
+
await waitForDisconnect(c.req.raw.signal, () => connectionDead, (resolve2) => {
|
|
823
|
+
onClosed = resolve2;
|
|
824
|
+
});
|
|
825
|
+
cleanup();
|
|
826
|
+
}
|
|
827
|
+
async function openSubscriptions(connection, writer, hold, ctx) {
|
|
828
|
+
const connected = { subscribedEvents: [...connection.allowed], deniedEvents: [...connection.denied] };
|
|
829
|
+
const producing = connected.subscribedEvents.filter((eventName) => ctx.router.events[eventName]._initial);
|
|
830
|
+
if (!ctx.subject || producing.length === 0) {
|
|
831
|
+
enqueueConnected(writer, { ...connected, initial: [] });
|
|
832
|
+
subscribeAllowed(connection);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
hold.hold();
|
|
836
|
+
subscribeAllowed(connection);
|
|
837
|
+
const served = await produceInitialFrames(producing, ctx.router, {
|
|
838
|
+
subject: ctx.subject,
|
|
839
|
+
principal: ctx.principal,
|
|
840
|
+
request: ctx.request
|
|
841
|
+
});
|
|
842
|
+
if (ctx.isDead()) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
enqueueConnected(writer, { ...connected, initial: served.map(({ eventName }) => eventName) });
|
|
846
|
+
served.forEach(({ eventName, frames }) => frames.forEach((data) => ctx.writeFrame(eventName, data)));
|
|
847
|
+
hold.release();
|
|
848
|
+
}
|
|
849
|
+
function enqueueConnected(writer, connected) {
|
|
850
|
+
writer.enqueue({
|
|
851
|
+
event: "connected",
|
|
852
|
+
data: JSON.stringify({ ...connected, timestamp: Date.now() })
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
function subscribeAllowed(connection) {
|
|
856
|
+
for (const eventName of connection.allowed) {
|
|
857
|
+
connection.unsubscribes.set(eventName, connection.subscribe(eventName));
|
|
858
|
+
}
|
|
859
|
+
sseLogger.info("SSE connection established", {
|
|
860
|
+
events: [...connection.allowed],
|
|
861
|
+
deniedEvents: [...connection.denied],
|
|
862
|
+
subscriptionCount: connection.unsubscribes.size
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
async function produceInitialFrames(events, router, ctx) {
|
|
866
|
+
const results = await Promise.all(events.map((eventName) => askInitial(eventName, router.events[eventName], ctx)));
|
|
867
|
+
return results.filter((frames) => frames !== null);
|
|
868
|
+
}
|
|
869
|
+
async function askInitial(eventName, eventDef, ctx) {
|
|
870
|
+
try {
|
|
871
|
+
const payloads = await eventDef._initial(ctx);
|
|
872
|
+
return { eventName, frames: payloads.map((payload) => serializeFrame(eventName, payload)) };
|
|
873
|
+
} catch {
|
|
874
|
+
sseLogger.warn("SSE initial frames could not be produced \u2014 none sent for this event", {
|
|
875
|
+
event: eventName,
|
|
876
|
+
subject: ctx.subject
|
|
509
877
|
});
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
clearInterval(pingTimer);
|
|
522
|
-
unsubscribes.forEach((fn) => fn());
|
|
523
|
-
writer?.close();
|
|
524
|
-
onClosed?.();
|
|
525
|
-
sseLogger.info("SSE dead connection cleaned up", {
|
|
526
|
-
events: allowedEvents,
|
|
527
|
-
reason
|
|
528
|
-
});
|
|
529
|
-
};
|
|
530
|
-
writer = createBoundedWriter(stream, maxQueue, (reason) => {
|
|
531
|
-
sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
|
|
532
|
-
cleanup(reason);
|
|
533
|
-
});
|
|
534
|
-
writer.enqueue({
|
|
535
|
-
event: "connected",
|
|
536
|
-
data: JSON.stringify({
|
|
537
|
-
subscribedEvents: allowedEvents,
|
|
538
|
-
timestamp: Date.now()
|
|
539
|
-
})
|
|
540
|
-
});
|
|
541
|
-
for (const eventName of allowedEvents) {
|
|
542
|
-
const eventDef = router.events[eventName];
|
|
543
|
-
if (!eventDef) {
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
const unsubscribe = eventDef.subscribe((payload) => {
|
|
547
|
-
if (connectionDead) return;
|
|
548
|
-
if (subject && authConfig?.filter?.[eventName]) {
|
|
549
|
-
if (!authConfig.filter[eventName](subject, payload)) {
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
messageId++;
|
|
554
|
-
sseLogger.debug("SSE sending event", {
|
|
555
|
-
event: eventName,
|
|
556
|
-
messageId
|
|
557
|
-
});
|
|
558
|
-
writer.enqueue({
|
|
559
|
-
id: String(messageId),
|
|
560
|
-
event: eventName,
|
|
561
|
-
data: serializeFrame(eventName, payload)
|
|
562
|
-
});
|
|
563
|
-
});
|
|
564
|
-
unsubscribes.push(unsubscribe);
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function createFrameHold(writer) {
|
|
882
|
+
let pending = null;
|
|
883
|
+
return {
|
|
884
|
+
send: (write) => {
|
|
885
|
+
if (!pending) {
|
|
886
|
+
write();
|
|
887
|
+
} else if (writer.reserve()) {
|
|
888
|
+
pending.push(write);
|
|
565
889
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
890
|
+
},
|
|
891
|
+
hold: () => {
|
|
892
|
+
pending = [];
|
|
893
|
+
},
|
|
894
|
+
release: () => {
|
|
895
|
+
const held = pending ?? [];
|
|
896
|
+
pending = null;
|
|
897
|
+
writer.clearReserved();
|
|
898
|
+
held.forEach((write) => write());
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function createFrameWriter(writer) {
|
|
903
|
+
let messageId = 0;
|
|
904
|
+
return (eventName, data) => {
|
|
905
|
+
messageId++;
|
|
906
|
+
sseLogger.debug("SSE sending event", {
|
|
907
|
+
event: eventName,
|
|
908
|
+
messageId
|
|
909
|
+
});
|
|
910
|
+
writer.enqueue({ id: String(messageId), event: eventName, data });
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
async function waitForDisconnect(abortSignal, isDead, onServerClose) {
|
|
914
|
+
if (abortSignal.aborted || isDead()) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
await new Promise((resolve2) => {
|
|
918
|
+
const onAbort = () => resolve2();
|
|
919
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
920
|
+
onServerClose(() => {
|
|
921
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
922
|
+
resolve2();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function createEventSubscriber(ctx) {
|
|
927
|
+
return (eventName) => {
|
|
928
|
+
const eventDef = ctx.router.events[eventName];
|
|
929
|
+
let active = true;
|
|
930
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
931
|
+
if (!active || ctx.isDead() || !passesFrameFilters(eventName, eventDef, payload, ctx)) {
|
|
932
|
+
return;
|
|
587
933
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
sseLogger.error("SSE stream error", {
|
|
591
|
-
error: err.message
|
|
592
|
-
});
|
|
934
|
+
const data = serializeFrame(eventName, payload);
|
|
935
|
+
ctx.send(() => ctx.writeFrame(eventName, data));
|
|
593
936
|
});
|
|
937
|
+
return () => {
|
|
938
|
+
active = false;
|
|
939
|
+
unsubscribe();
|
|
940
|
+
};
|
|
594
941
|
};
|
|
595
942
|
}
|
|
943
|
+
function passesFrameFilters(eventName, eventDef, payload, ctx) {
|
|
944
|
+
const globalFilter = ctx.authConfig?.filter?.[eventName];
|
|
945
|
+
if (ctx.subject && globalFilter && !globalFilter(ctx.subject, payload)) {
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
if (!eventDef._filter) {
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
return eventDef._filter(payload, { subject: ctx.subject, request: ctx.request }) === true;
|
|
953
|
+
} catch {
|
|
954
|
+
sseLogger.warn("SSE event filter threw \u2014 frame dropped", { event: eventName, subject: ctx.subject });
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
596
958
|
async function authenticateToken(c, tokenManager) {
|
|
597
959
|
if (!tokenManager) {
|
|
598
960
|
return void 0;
|
|
@@ -608,7 +970,7 @@ function parseRequestedEvents(c) {
|
|
|
608
970
|
if (!eventsParam) {
|
|
609
971
|
return null;
|
|
610
972
|
}
|
|
611
|
-
return eventsParam.split(",").map((e) => e.trim());
|
|
973
|
+
return [...new Set(eventsParam.split(",").map((e) => e.trim()))];
|
|
612
974
|
}
|
|
613
975
|
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
614
976
|
if (!subject || !authConfig?.authorize) {
|
|
@@ -620,6 +982,12 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
|
620
982
|
}
|
|
621
983
|
return allowed;
|
|
622
984
|
}
|
|
985
|
+
|
|
986
|
+
// src/event/sse/paths.ts
|
|
987
|
+
var DEFAULT_SSE_STREAM_PATH = "/events/stream";
|
|
988
|
+
function sseTokenPath(streamPath) {
|
|
989
|
+
return streamPath.replace(/\/[^/]+$/, "/token");
|
|
990
|
+
}
|
|
623
991
|
function hashToken(token) {
|
|
624
992
|
return createHash("sha256").update(token).digest("hex");
|
|
625
993
|
}
|
|
@@ -1080,6 +1448,7 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
|
|
|
1080
1448
|
// src/server/namespace.ts
|
|
1081
1449
|
var CORE_NAMESPACE = "/_core";
|
|
1082
1450
|
var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
|
|
1451
|
+
var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
|
|
1083
1452
|
var LEGACY_HEALTH_PATH = "/health";
|
|
1084
1453
|
|
|
1085
1454
|
// src/server/shutdown-manager.ts
|
|
@@ -1480,6 +1849,448 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1480
1849
|
};
|
|
1481
1850
|
}
|
|
1482
1851
|
|
|
1852
|
+
// src/route/route-builder.ts
|
|
1853
|
+
var RouteBuilder = class _RouteBuilder {
|
|
1854
|
+
_method;
|
|
1855
|
+
_path;
|
|
1856
|
+
_input;
|
|
1857
|
+
_interceptor;
|
|
1858
|
+
_middlewares;
|
|
1859
|
+
_skipMiddlewares;
|
|
1860
|
+
_contract;
|
|
1861
|
+
_effect;
|
|
1862
|
+
_summary;
|
|
1863
|
+
/**
|
|
1864
|
+
* Create a new RouteBuilder with copied properties and optional overrides
|
|
1865
|
+
*/
|
|
1866
|
+
clone(overrides) {
|
|
1867
|
+
const builder = new _RouteBuilder();
|
|
1868
|
+
builder._method = this._method;
|
|
1869
|
+
builder._path = this._path;
|
|
1870
|
+
builder._input = overrides?.input ?? this._input;
|
|
1871
|
+
builder._interceptor = overrides?.interceptor ?? this._interceptor;
|
|
1872
|
+
builder._middlewares = overrides?.middlewares ?? this._middlewares;
|
|
1873
|
+
builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
|
|
1874
|
+
builder._contract = overrides?.contract ?? this._contract;
|
|
1875
|
+
builder._effect = overrides?.effect ?? this._effect;
|
|
1876
|
+
builder._summary = overrides?.summary ?? this._summary;
|
|
1877
|
+
return builder;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Define input schemas
|
|
1881
|
+
*
|
|
1882
|
+
* @example
|
|
1883
|
+
* ```ts
|
|
1884
|
+
* route.get('/users/:id')
|
|
1885
|
+
* .input({
|
|
1886
|
+
* params: Type.Object({ id: Type.String() }),
|
|
1887
|
+
* query: Type.Object({ page: Type.Number() }),
|
|
1888
|
+
* headers: Type.Object({ authorization: Type.String() })
|
|
1889
|
+
* })
|
|
1890
|
+
* .handler(async (c) => {
|
|
1891
|
+
* const { params, query, headers } = await c.data();
|
|
1892
|
+
* // params = { id: string }
|
|
1893
|
+
* // query = { page: number }
|
|
1894
|
+
* // headers = { authorization: string }
|
|
1895
|
+
* })
|
|
1896
|
+
* ```
|
|
1897
|
+
*/
|
|
1898
|
+
input(input) {
|
|
1899
|
+
return this.clone({ input });
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Define fields injected by interceptors
|
|
1903
|
+
*
|
|
1904
|
+
* These fields are:
|
|
1905
|
+
* - Available in the handler (merged with input)
|
|
1906
|
+
* - Excluded from client types (codegen uses only input)
|
|
1907
|
+
* - Not validated by route input schema (injected by middleware)
|
|
1908
|
+
*
|
|
1909
|
+
* Use this when middleware/interceptors add fields to the request
|
|
1910
|
+
* before it reaches the handler.
|
|
1911
|
+
*
|
|
1912
|
+
* @example
|
|
1913
|
+
* ```ts
|
|
1914
|
+
* // Auth interceptor injects crypto key fields
|
|
1915
|
+
* route.post('/_auth/login')
|
|
1916
|
+
* .input({
|
|
1917
|
+
* body: Type.Object({
|
|
1918
|
+
* email: Type.String(),
|
|
1919
|
+
* password: Type.String()
|
|
1920
|
+
* })
|
|
1921
|
+
* })
|
|
1922
|
+
* .interceptor({
|
|
1923
|
+
* body: Type.Object({
|
|
1924
|
+
* publicKey: Type.String(),
|
|
1925
|
+
* keyId: Type.String(),
|
|
1926
|
+
* fingerprint: Type.String()
|
|
1927
|
+
* })
|
|
1928
|
+
* })
|
|
1929
|
+
* .handler(async (c) => {
|
|
1930
|
+
* const { body } = await c.data();
|
|
1931
|
+
* // body type: { email, password, publicKey, keyId, fingerprint }
|
|
1932
|
+
* // Client only sees: { email, password }
|
|
1933
|
+
* return loginService(body);
|
|
1934
|
+
* });
|
|
1935
|
+
* ```
|
|
1936
|
+
*/
|
|
1937
|
+
interceptor(interceptor) {
|
|
1938
|
+
return this.clone({ interceptor });
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Add middlewares to the route
|
|
1942
|
+
*
|
|
1943
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1944
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1945
|
+
* deduplicated to prevent double execution.
|
|
1946
|
+
*
|
|
1947
|
+
* @example
|
|
1948
|
+
* ```ts
|
|
1949
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1950
|
+
*
|
|
1951
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1952
|
+
* route.get('/users')
|
|
1953
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1954
|
+
*
|
|
1955
|
+
* // With regular middleware handlers
|
|
1956
|
+
* route.get('/users')
|
|
1957
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1958
|
+
* ```
|
|
1959
|
+
*/
|
|
1960
|
+
middleware(middlewares) {
|
|
1961
|
+
return this.clone({ middlewares });
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Add middlewares to the route (alias for `.middleware()`)
|
|
1965
|
+
*
|
|
1966
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1967
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1968
|
+
* deduplicated to prevent double execution.
|
|
1969
|
+
*
|
|
1970
|
+
* @example
|
|
1971
|
+
* ```ts
|
|
1972
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1973
|
+
*
|
|
1974
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1975
|
+
* route.get('/users')
|
|
1976
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1977
|
+
*
|
|
1978
|
+
* // With regular middleware handlers
|
|
1979
|
+
* route.get('/users')
|
|
1980
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1981
|
+
* ```
|
|
1982
|
+
*/
|
|
1983
|
+
use(middlewares) {
|
|
1984
|
+
return this.middleware(middlewares);
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Skip server-level named middlewares
|
|
1988
|
+
*
|
|
1989
|
+
* Useful for public endpoints that should bypass auth or rate limiting
|
|
1990
|
+
*
|
|
1991
|
+
* @param middlewareNames - Array of middleware names to skip, or '*' to skip all
|
|
1992
|
+
*
|
|
1993
|
+
* @example
|
|
1994
|
+
* ```ts
|
|
1995
|
+
* // Skip specific middlewares
|
|
1996
|
+
* route.get('/status')
|
|
1997
|
+
* .skip(['auth', 'rateLimit'])
|
|
1998
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1999
|
+
*
|
|
2000
|
+
* // Skip only auth (still apply rate limiting)
|
|
2001
|
+
* route.get('/public-data')
|
|
2002
|
+
* .skip(['auth'])
|
|
2003
|
+
* .handler(async (c) => { ... });
|
|
2004
|
+
*
|
|
2005
|
+
* // Skip all middlewares
|
|
2006
|
+
* route.get('/public-health')
|
|
2007
|
+
* .skip('*')
|
|
2008
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
2009
|
+
* ```
|
|
2010
|
+
*/
|
|
2011
|
+
skip(middlewareNames) {
|
|
2012
|
+
return this.clone({ skipMiddlewares: middlewareNames });
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Publish this route as a versioned contract operation
|
|
2016
|
+
*
|
|
2017
|
+
* Marks the route as a promise to clients that are compiled and deployed
|
|
2018
|
+
* separately from the server — a mobile app, an external API consumer.
|
|
2019
|
+
* The `@spfn/core:contract` generator writes every contracted route into
|
|
2020
|
+
* `contracts/current.json`, and the build refuses a change that would break
|
|
2021
|
+
* an already-released client.
|
|
2022
|
+
*
|
|
2023
|
+
* Routes without `.contract()` are unaffected: they simply do not appear in
|
|
2024
|
+
* the contract. A web client needs nothing here — it derives its types from
|
|
2025
|
+
* the router in the same build.
|
|
2026
|
+
*
|
|
2027
|
+
* @example
|
|
2028
|
+
* ```ts
|
|
2029
|
+
* export const getUser = route.get('/users/:id')
|
|
2030
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
2031
|
+
* .contract({
|
|
2032
|
+
* since: '1.2.0',
|
|
2033
|
+
* auth: 'clientProofV1',
|
|
2034
|
+
* requiresSession: true,
|
|
2035
|
+
* response: Type.Object({
|
|
2036
|
+
* id: Type.String(),
|
|
2037
|
+
* name: Type.String(),
|
|
2038
|
+
* email: Type.Optional(Type.String()),
|
|
2039
|
+
* }),
|
|
2040
|
+
* })
|
|
2041
|
+
* .handler(async (c) => { ... });
|
|
2042
|
+
* ```
|
|
2043
|
+
*/
|
|
2044
|
+
contract(contract) {
|
|
2045
|
+
return this.clone({ contract });
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Declare what invoking this route does
|
|
2049
|
+
*
|
|
2050
|
+
* Read by a command surface (`@spfn/core/surface`, `@spfn/core/ops`),
|
|
2051
|
+
* which announces it in its manifest so a machine caller can tell a read
|
|
2052
|
+
* from a write before it calls — `spfn ops call` asks for `--yes` before a
|
|
2053
|
+
* `destructive` command. A surface built with `defineSurface` refuses an
|
|
2054
|
+
* app command that does not declare one; on `/_ops` it is optional.
|
|
2055
|
+
*
|
|
2056
|
+
* Nothing else reads it: codegen, the route map, and the contract are
|
|
2057
|
+
* unaffected, and a route outside any surface simply carries it.
|
|
2058
|
+
*
|
|
2059
|
+
* @example
|
|
2060
|
+
* ```ts
|
|
2061
|
+
* export const pageAdd = app.route.post('/pages')
|
|
2062
|
+
* .input({ body: PageAddBody })
|
|
2063
|
+
* .effect('write')
|
|
2064
|
+
* .summary('Add a page to the blueprint')
|
|
2065
|
+
* .handler(async (c) => { ... });
|
|
2066
|
+
* ```
|
|
2067
|
+
*/
|
|
2068
|
+
effect(effect) {
|
|
2069
|
+
return this.clone({ effect });
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Describe this route in one line for a command surface's manifest
|
|
2073
|
+
*
|
|
2074
|
+
* A surface announces it beside the command in `spfn ops list` and
|
|
2075
|
+
* `--describe`, and refuses one that is empty or longer than 500
|
|
2076
|
+
* characters. Codegen, the route map, and the contract ignore it. On a
|
|
2077
|
+
* module command the module's own summary wins.
|
|
2078
|
+
*
|
|
2079
|
+
* @example
|
|
2080
|
+
* ```ts
|
|
2081
|
+
* export const pageAdd = app.route.post('/pages')
|
|
2082
|
+
* .effect('write')
|
|
2083
|
+
* .summary('Add a page to the blueprint')
|
|
2084
|
+
* .handler(async (c) => { ... });
|
|
2085
|
+
* ```
|
|
2086
|
+
*/
|
|
2087
|
+
summary(summary) {
|
|
2088
|
+
return this.clone({ summary });
|
|
2089
|
+
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Define handler function
|
|
2092
|
+
*
|
|
2093
|
+
* Response type is automatically inferred from the return value.
|
|
2094
|
+
* Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
|
|
2095
|
+
*
|
|
2096
|
+
* @example
|
|
2097
|
+
* ```ts
|
|
2098
|
+
* // Direct return - type inferred from data
|
|
2099
|
+
* route.get('/users/:id')
|
|
2100
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
2101
|
+
* .handler(async (c) => {
|
|
2102
|
+
* const { params } = await c.data();
|
|
2103
|
+
* return await getUser(params.id); // Type: User
|
|
2104
|
+
* })
|
|
2105
|
+
*
|
|
2106
|
+
* // Using c.created() - returns data with 201 status, type preserved
|
|
2107
|
+
* route.post('/users')
|
|
2108
|
+
* .input({ body: Type.Object({ name: Type.String() }) })
|
|
2109
|
+
* .handler(async (c) => {
|
|
2110
|
+
* const { body } = await c.data();
|
|
2111
|
+
* return c.created(await createUser(body)); // Type: User
|
|
2112
|
+
* })
|
|
2113
|
+
*
|
|
2114
|
+
* // Using c.paginated() - returns PaginatedResult<T>
|
|
2115
|
+
* route.get('/users')
|
|
2116
|
+
* .handler(async (c) => {
|
|
2117
|
+
* const users = await getUsers();
|
|
2118
|
+
* return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
|
|
2119
|
+
* })
|
|
2120
|
+
*
|
|
2121
|
+
* // Using c.noContent() - returns void
|
|
2122
|
+
* route.delete('/users/:id')
|
|
2123
|
+
* .handler(async (c) => {
|
|
2124
|
+
* await deleteUser(params.id);
|
|
2125
|
+
* return c.noContent(); // Type: void
|
|
2126
|
+
* })
|
|
2127
|
+
*
|
|
2128
|
+
* // Using c.json() - returns Response (type inference lost)
|
|
2129
|
+
* // Use only when you need custom status codes not covered by helpers
|
|
2130
|
+
* route.get('/custom')
|
|
2131
|
+
* .handler(async (c) => {
|
|
2132
|
+
* return c.json({ data }, 418); // Type: Response
|
|
2133
|
+
* })
|
|
2134
|
+
* ```
|
|
2135
|
+
*/
|
|
2136
|
+
handler(fn) {
|
|
2137
|
+
return {
|
|
2138
|
+
method: this._method,
|
|
2139
|
+
path: this._path,
|
|
2140
|
+
input: this._input,
|
|
2141
|
+
interceptor: this._interceptor,
|
|
2142
|
+
middlewares: this._middlewares,
|
|
2143
|
+
skipMiddlewares: this._skipMiddlewares,
|
|
2144
|
+
contract: this._contract,
|
|
2145
|
+
...this._effect !== void 0 ? { effect: this._effect } : {},
|
|
2146
|
+
...this._summary !== void 0 ? { summary: this._summary } : {},
|
|
2147
|
+
handler: fn,
|
|
2148
|
+
_input: {},
|
|
2149
|
+
_interceptor: {},
|
|
2150
|
+
_response: {}
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
function createMethodRoute(method) {
|
|
2155
|
+
return (path) => {
|
|
2156
|
+
const builder = new RouteBuilder();
|
|
2157
|
+
builder._method = method;
|
|
2158
|
+
builder._path = path;
|
|
2159
|
+
return builder;
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
var route = {
|
|
2163
|
+
get: createMethodRoute("GET"),
|
|
2164
|
+
post: createMethodRoute("POST"),
|
|
2165
|
+
put: createMethodRoute("PUT"),
|
|
2166
|
+
patch: createMethodRoute("PATCH"),
|
|
2167
|
+
delete: createMethodRoute("DELETE")
|
|
2168
|
+
};
|
|
2169
|
+
|
|
2170
|
+
// src/route/router.ts
|
|
2171
|
+
function createRouterInstance(state2) {
|
|
2172
|
+
const router = {
|
|
2173
|
+
routes: state2.routes,
|
|
2174
|
+
_routes: state2.routes,
|
|
2175
|
+
_packageRouters: state2.packageRouters,
|
|
2176
|
+
_globalMiddlewares: state2.globalMiddlewares,
|
|
2177
|
+
_contractVersion: state2.contractVersion,
|
|
2178
|
+
_publishesRouteMap: state2.publishesRouteMap,
|
|
2179
|
+
packages(routers) {
|
|
2180
|
+
const newPackageRouters = [...state2.packageRouters, ...routers];
|
|
2181
|
+
for (const pkgRouter of routers) {
|
|
2182
|
+
if (pkgRouter._packageRouters?.length > 0) {
|
|
2183
|
+
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
assertDistinctSurfaces(newPackageRouters);
|
|
2187
|
+
return carryMarkers(router, createRouterInstance({ ...state2, packageRouters: newPackageRouters }));
|
|
2188
|
+
},
|
|
2189
|
+
use(middlewares) {
|
|
2190
|
+
return carryMarkers(router, createRouterInstance({
|
|
2191
|
+
...state2,
|
|
2192
|
+
globalMiddlewares: [...state2.globalMiddlewares, ...middlewares]
|
|
2193
|
+
}));
|
|
2194
|
+
},
|
|
2195
|
+
contractVersion(version) {
|
|
2196
|
+
assertContractVersion(version);
|
|
2197
|
+
return carryMarkers(router, createRouterInstance({ ...state2, contractVersion: version }));
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
return router;
|
|
2201
|
+
}
|
|
2202
|
+
var MARKERS = ["_surfaceNamespace", "_stateManifest"];
|
|
2203
|
+
function carryMarkers(from, to) {
|
|
2204
|
+
for (const marker of MARKERS) {
|
|
2205
|
+
if (from[marker] !== void 0) {
|
|
2206
|
+
Object.defineProperty(to, marker, { value: from[marker], enumerable: false });
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
return to;
|
|
2210
|
+
}
|
|
2211
|
+
function assertContractVersion(version) {
|
|
2212
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
|
|
2213
|
+
throw new Error(
|
|
2214
|
+
`contractVersion("${version}") is not a version of the form major.minor.patch. The released snapshot is named from this value and releases are compared by it.`
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function assertDistinctSurfaces(packageRouters) {
|
|
2219
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
2220
|
+
for (const router of packageRouters) {
|
|
2221
|
+
const namespace = router._surfaceNamespace;
|
|
2222
|
+
if (namespace === void 0) {
|
|
2223
|
+
continue;
|
|
2224
|
+
}
|
|
2225
|
+
if (namespaces.has(namespace)) {
|
|
2226
|
+
throw new Error(
|
|
2227
|
+
`.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
namespaces.add(namespace);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
function defineRouter(routes) {
|
|
2234
|
+
return createRouterInstance({
|
|
2235
|
+
routes,
|
|
2236
|
+
packageRouters: [],
|
|
2237
|
+
globalMiddlewares: [],
|
|
2238
|
+
contractVersion: null,
|
|
2239
|
+
publishesRouteMap: true
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// src/route/validation.ts
|
|
2244
|
+
FormatRegistry.Set(
|
|
2245
|
+
"email",
|
|
2246
|
+
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
2247
|
+
);
|
|
2248
|
+
FormatRegistry.Set(
|
|
2249
|
+
"uri",
|
|
2250
|
+
(value) => /^https?:\/\/.+/.test(value)
|
|
2251
|
+
);
|
|
2252
|
+
FormatRegistry.Set(
|
|
2253
|
+
"uuid",
|
|
2254
|
+
(value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
2255
|
+
);
|
|
2256
|
+
FormatRegistry.Set(
|
|
2257
|
+
"date",
|
|
2258
|
+
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
2259
|
+
);
|
|
2260
|
+
FormatRegistry.Set(
|
|
2261
|
+
"date-time",
|
|
2262
|
+
(value) => !isNaN(Date.parse(value))
|
|
2263
|
+
);
|
|
2264
|
+
|
|
2265
|
+
// src/server/server-time.ts
|
|
2266
|
+
var CORE_TIME_OPERATION_ID = "core.time";
|
|
2267
|
+
var ServerTimeResponseSchema = Type.Object({
|
|
2268
|
+
serverTimeMillis: Type.Integer()
|
|
2269
|
+
}, { additionalProperties: false });
|
|
2270
|
+
var systemServerClock = {
|
|
2271
|
+
now: () => Date.now()
|
|
2272
|
+
};
|
|
2273
|
+
function createCoreTimeRoute(clock = systemServerClock) {
|
|
2274
|
+
return route.get(CORE_TIME_PATH).skip("*").contract({
|
|
2275
|
+
since: "0.3.0",
|
|
2276
|
+
auth: "none",
|
|
2277
|
+
requiresSession: false,
|
|
2278
|
+
response: ServerTimeResponseSchema
|
|
2279
|
+
}).handler(async (c) => {
|
|
2280
|
+
const response = {
|
|
2281
|
+
serverTimeMillis: clock.now()
|
|
2282
|
+
};
|
|
2283
|
+
c.raw.header("Cache-Control", "no-store");
|
|
2284
|
+
return response;
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
var CORE_TIME_ROUTE = createCoreTimeRoute();
|
|
2288
|
+
function createCoreTimeRouter(clock) {
|
|
2289
|
+
return defineRouter({
|
|
2290
|
+
[CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
|
|
1483
2294
|
// src/server/create-server.ts
|
|
1484
2295
|
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1485
2296
|
async function createServer(config) {
|
|
@@ -1522,6 +2333,7 @@ async function createAutoConfiguredApp(config) {
|
|
|
1522
2333
|
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1523
2334
|
await applyProxyGuard(app, config);
|
|
1524
2335
|
applyRateLimit(config);
|
|
2336
|
+
registerCoreTimeEndpoint(app, config);
|
|
1525
2337
|
if (Array.isArray(config?.use)) {
|
|
1526
2338
|
config.use.forEach((mw) => app.use("*", mw));
|
|
1527
2339
|
}
|
|
@@ -1571,7 +2383,7 @@ async function applyProxyGuard(app, config) {
|
|
|
1571
2383
|
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1572
2384
|
}
|
|
1573
2385
|
}
|
|
1574
|
-
const autoSkip = [CORE_HEALTH_PATH, LEGACY_HEALTH_PATH];
|
|
2386
|
+
const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
|
|
1575
2387
|
if (config?.healthCheck?.path) {
|
|
1576
2388
|
autoSkip.push(config.healthCheck.path);
|
|
1577
2389
|
}
|
|
@@ -1628,6 +2440,10 @@ function resolveHealthCheck(config) {
|
|
|
1628
2440
|
detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
|
|
1629
2441
|
};
|
|
1630
2442
|
}
|
|
2443
|
+
function registerCoreTimeEndpoint(app, config) {
|
|
2444
|
+
registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
|
|
2445
|
+
serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
|
|
2446
|
+
}
|
|
1631
2447
|
function registerCoreHealthEndpoint(app, config) {
|
|
1632
2448
|
const { enabled, path, detailed } = resolveHealthCheck(config);
|
|
1633
2449
|
if (!enabled) {
|
|
@@ -1729,8 +2545,9 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1729
2545
|
return;
|
|
1730
2546
|
}
|
|
1731
2547
|
const eventsConfig = config.eventsConfig ?? {};
|
|
1732
|
-
const streamPath = eventsConfig.path ??
|
|
2548
|
+
const streamPath = eventsConfig.path ?? DEFAULT_SSE_STREAM_PATH;
|
|
1733
2549
|
const authConfig = eventsConfig.auth;
|
|
2550
|
+
assertEventContractServed(config.events._contract, streamPath, !!authConfig?.enabled);
|
|
1734
2551
|
const debug = isDebugMode(config);
|
|
1735
2552
|
let tokenManager;
|
|
1736
2553
|
if (authConfig?.enabled) {
|
|
@@ -1753,7 +2570,7 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1753
2570
|
ttl: authConfig.tokenTtl,
|
|
1754
2571
|
store
|
|
1755
2572
|
});
|
|
1756
|
-
const tokenPath = streamPath
|
|
2573
|
+
const tokenPath = sseTokenPath(streamPath);
|
|
1757
2574
|
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
1758
2575
|
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1759
2576
|
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
@@ -1782,6 +2599,23 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1782
2599
|
});
|
|
1783
2600
|
}
|
|
1784
2601
|
}
|
|
2602
|
+
function assertEventContractServed(contract, streamPath, authEnabled) {
|
|
2603
|
+
if (!contract) {
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const contractPath = contract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
|
|
2607
|
+
if (contractPath !== streamPath) {
|
|
2608
|
+
throw new Error(
|
|
2609
|
+
`SSE event router contract declares streamPath ${contractPath} but .events() registers ${streamPath}; the contract would describe a path the server does not serve`
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
const servedAuth = authEnabled ? "tokenExchange" : "none";
|
|
2613
|
+
if (contract.auth !== servedAuth) {
|
|
2614
|
+
throw new Error(
|
|
2615
|
+
`SSE event router contract declares auth ${contract.auth} but .events() serves auth ${servedAuth}; the contract would describe an auth mode the server does not serve`
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
1785
2619
|
function isDebugMode(config) {
|
|
1786
2620
|
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
1787
2621
|
}
|
|
@@ -1873,6 +2707,31 @@ function shouldSweepOrphanSchedules() {
|
|
|
1873
2707
|
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
1874
2708
|
}
|
|
1875
2709
|
|
|
2710
|
+
// src/job/queue-policy.ts
|
|
2711
|
+
function resolveQueuePolicy(job2) {
|
|
2712
|
+
if (job2.options?.policy) {
|
|
2713
|
+
return job2.options.policy;
|
|
2714
|
+
}
|
|
2715
|
+
if (job2.runOnce || job2.options?.singletonKey) {
|
|
2716
|
+
return "exclusive";
|
|
2717
|
+
}
|
|
2718
|
+
return "standard";
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// src/job/retry-options.ts
|
|
2722
|
+
function toPgBossRetryOptions(options) {
|
|
2723
|
+
return {
|
|
2724
|
+
retryLimit: options?.retryLimit ?? 3,
|
|
2725
|
+
retryDelay: Math.ceil((options?.retryDelay ?? 1e3) / 1e3),
|
|
2726
|
+
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
2727
|
+
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
2728
|
+
retryBackoff: options?.retryBackoff ?? true
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// src/job/job-builder.ts
|
|
2733
|
+
logger.child("@spfn/core:job");
|
|
2734
|
+
|
|
1876
2735
|
// src/job/job-router.ts
|
|
1877
2736
|
function isJobDef(value) {
|
|
1878
2737
|
return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
|
|
@@ -1880,29 +2739,44 @@ function isJobDef(value) {
|
|
|
1880
2739
|
function isJobRouter(value) {
|
|
1881
2740
|
return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
|
|
1882
2741
|
}
|
|
1883
|
-
function
|
|
1884
|
-
|
|
2742
|
+
function defineJobRouter(jobs) {
|
|
2743
|
+
return {
|
|
2744
|
+
jobs,
|
|
2745
|
+
_jobs: jobs
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
function mergeJobRouters(base, extra) {
|
|
2749
|
+
for (const key of Object.keys(extra.jobs)) {
|
|
2750
|
+
if (key in base.jobs) {
|
|
2751
|
+
throw new Error(
|
|
2752
|
+
`jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
return defineJobRouter({ ...base.jobs, ...extra.jobs });
|
|
2757
|
+
}
|
|
2758
|
+
function collectJobEntries(router, prefix = "") {
|
|
2759
|
+
const entries = [];
|
|
1885
2760
|
for (const [key, value] of Object.entries(router.jobs)) {
|
|
1886
|
-
const
|
|
2761
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
1887
2762
|
if (isJobRouter(value)) {
|
|
1888
|
-
|
|
2763
|
+
entries.push(...collectJobEntries(value, path));
|
|
1889
2764
|
} else if (isJobDef(value)) {
|
|
1890
|
-
|
|
2765
|
+
entries.push({ key: path, job: value });
|
|
1891
2766
|
}
|
|
1892
2767
|
}
|
|
1893
|
-
return
|
|
2768
|
+
return entries;
|
|
2769
|
+
}
|
|
2770
|
+
function collectJobs(router, prefix = "") {
|
|
2771
|
+
return collectJobEntries(router, prefix).map((entry) => entry.job);
|
|
1894
2772
|
}
|
|
1895
|
-
var
|
|
2773
|
+
var jobLogger3 = logger.child("@spfn/core:job");
|
|
1896
2774
|
function getEventQueueName(eventName) {
|
|
1897
2775
|
return `event:${eventName}`;
|
|
1898
2776
|
}
|
|
1899
2777
|
function getDefaultJobOptions(options) {
|
|
1900
2778
|
return {
|
|
1901
|
-
|
|
1902
|
-
retryDelay: options?.retryDelay ?? 1e3,
|
|
1903
|
-
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
1904
|
-
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
1905
|
-
retryBackoff: options?.retryBackoff ?? true,
|
|
2779
|
+
...toPgBossRetryOptions(options),
|
|
1906
2780
|
expireInSeconds: options?.expireInSeconds ?? 300
|
|
1907
2781
|
};
|
|
1908
2782
|
}
|
|
@@ -1913,11 +2787,12 @@ async function registerJobs(router) {
|
|
|
1913
2787
|
"pg-boss not initialized. Call initBoss() before registerJobs()"
|
|
1914
2788
|
);
|
|
1915
2789
|
}
|
|
2790
|
+
assertUniqueJobNames(router);
|
|
1916
2791
|
const jobs = collectJobs(router);
|
|
1917
2792
|
const clearOnStart = shouldClearOnStart();
|
|
1918
|
-
|
|
2793
|
+
jobLogger3.info(`Registering ${jobs.length} job(s)...`);
|
|
1919
2794
|
if (clearOnStart) {
|
|
1920
|
-
|
|
2795
|
+
jobLogger3.info("Clearing existing jobs before registration...");
|
|
1921
2796
|
for (const job2 of jobs) {
|
|
1922
2797
|
await boss.deleteAllJobs(job2.name);
|
|
1923
2798
|
if (job2.subscribedEvent) {
|
|
@@ -1925,7 +2800,7 @@ async function registerJobs(router) {
|
|
|
1925
2800
|
await boss.deleteAllJobs(eventQueue);
|
|
1926
2801
|
}
|
|
1927
2802
|
}
|
|
1928
|
-
|
|
2803
|
+
jobLogger3.info("Existing jobs cleared");
|
|
1929
2804
|
}
|
|
1930
2805
|
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
1931
2806
|
for (const job2 of jobs) {
|
|
@@ -1936,42 +2811,77 @@ async function registerJobs(router) {
|
|
|
1936
2811
|
if (shouldSweepOrphanSchedules()) {
|
|
1937
2812
|
await sweepOrphanSchedules(boss);
|
|
1938
2813
|
}
|
|
1939
|
-
|
|
2814
|
+
jobLogger3.info("All jobs registered successfully");
|
|
2815
|
+
}
|
|
2816
|
+
function assertUniqueJobNames(router) {
|
|
2817
|
+
const keysByName = /* @__PURE__ */ new Map();
|
|
2818
|
+
for (const { key, job: job2 } of collectJobEntries(router)) {
|
|
2819
|
+
const existingKey = keysByName.get(job2.name);
|
|
2820
|
+
if (existingKey) {
|
|
2821
|
+
throw new Error(
|
|
2822
|
+
`registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
keysByName.set(job2.name, key);
|
|
2826
|
+
}
|
|
1940
2827
|
}
|
|
1941
2828
|
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
1942
2829
|
async function sweepOrphanSchedules(boss) {
|
|
1943
2830
|
if (registeredCronNames.size === 0) {
|
|
1944
|
-
|
|
2831
|
+
jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
1945
2832
|
return;
|
|
1946
2833
|
}
|
|
1947
2834
|
try {
|
|
1948
2835
|
const schedules = await boss.getSchedules();
|
|
1949
2836
|
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
1950
2837
|
if (orphans.length === 0) {
|
|
1951
|
-
|
|
2838
|
+
jobLogger3.debug("No orphan schedules found");
|
|
1952
2839
|
return;
|
|
1953
2840
|
}
|
|
1954
2841
|
await Promise.all(orphans.map(async (orphan) => {
|
|
1955
2842
|
try {
|
|
1956
2843
|
await boss.unschedule(orphan.name, orphan.key);
|
|
1957
|
-
|
|
2844
|
+
jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
|
|
1958
2845
|
} catch (error) {
|
|
1959
|
-
|
|
2846
|
+
jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
1960
2847
|
error: error instanceof Error ? error.message : String(error)
|
|
1961
2848
|
});
|
|
1962
2849
|
}
|
|
1963
2850
|
}));
|
|
1964
2851
|
} catch (error) {
|
|
1965
|
-
|
|
2852
|
+
jobLogger3.error("Orphan schedule sweep failed", {
|
|
1966
2853
|
error: error instanceof Error ? error.message : String(error)
|
|
1967
2854
|
});
|
|
1968
2855
|
}
|
|
1969
2856
|
}
|
|
1970
|
-
async function ensureQueue(boss, queueName) {
|
|
1971
|
-
await boss.createQueue(queueName);
|
|
2857
|
+
async function ensureQueue(boss, queueName, policy) {
|
|
2858
|
+
await boss.createQueue(queueName, { policy });
|
|
2859
|
+
const existing = await boss.getQueue(queueName);
|
|
2860
|
+
if (!existing || existing.policy === policy) {
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
if (shouldClearOnStart()) {
|
|
2864
|
+
await boss.deleteQueue(queueName);
|
|
2865
|
+
await boss.createQueue(queueName, { policy });
|
|
2866
|
+
jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
jobLogger3.error(
|
|
2870
|
+
`[Queue:${queueName}] policy mismatch: the queue exists as "${existing.policy}" but its jobs require "${policy}". Delete the queue in a quiet window (getBoss().deleteQueue('${queueName}') drops its job rows) or set clearOnStart in development; until then singletonKey/runOnce on this queue do not deduplicate`
|
|
2871
|
+
);
|
|
2872
|
+
}
|
|
2873
|
+
function createQueueEnsurer(boss, policy) {
|
|
2874
|
+
const ensured = /* @__PURE__ */ new Set();
|
|
2875
|
+
return async (queueName) => {
|
|
2876
|
+
if (ensured.has(queueName)) {
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
ensured.add(queueName);
|
|
2880
|
+
await ensureQueue(boss, queueName, policy);
|
|
2881
|
+
};
|
|
1972
2882
|
}
|
|
1973
2883
|
async function executeJobHandler(job2, pgBossJob) {
|
|
1974
|
-
|
|
2884
|
+
jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1975
2885
|
const startTime = Date.now();
|
|
1976
2886
|
try {
|
|
1977
2887
|
if (job2.inputSchema) {
|
|
@@ -1980,13 +2890,13 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1980
2890
|
await job2.handler();
|
|
1981
2891
|
}
|
|
1982
2892
|
const duration = Date.now() - startTime;
|
|
1983
|
-
|
|
2893
|
+
jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1984
2894
|
jobId: pgBossJob.id,
|
|
1985
2895
|
duration
|
|
1986
2896
|
});
|
|
1987
2897
|
} catch (error) {
|
|
1988
2898
|
const duration = Date.now() - startTime;
|
|
1989
|
-
|
|
2899
|
+
jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1990
2900
|
jobId: pgBossJob.id,
|
|
1991
2901
|
duration,
|
|
1992
2902
|
error: error instanceof Error ? error.message : String(error)
|
|
@@ -1994,8 +2904,8 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1994
2904
|
throw error;
|
|
1995
2905
|
}
|
|
1996
2906
|
}
|
|
1997
|
-
async function registerWorker(boss, job2, queueName) {
|
|
1998
|
-
await
|
|
2907
|
+
async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
|
|
2908
|
+
await ensureQueueOnce(queueName);
|
|
1999
2909
|
const batchSize = job2.options?.batchSize ?? 1;
|
|
2000
2910
|
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
2001
2911
|
await boss.work(
|
|
@@ -2029,28 +2939,28 @@ function connectEventToQueue(boss, job2, queueName) {
|
|
|
2029
2939
|
eventDef._registerJobQueue(queueName, async (queue, payload) => {
|
|
2030
2940
|
await boss.send(queue, payload, getDefaultJobOptions(job2.options));
|
|
2031
2941
|
});
|
|
2032
|
-
|
|
2942
|
+
jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
|
|
2033
2943
|
}
|
|
2034
|
-
async function registerCronSchedule(boss, job2) {
|
|
2944
|
+
async function registerCronSchedule(boss, job2, ensureQueueOnce) {
|
|
2035
2945
|
if (!job2.cronExpression) {
|
|
2036
2946
|
return;
|
|
2037
2947
|
}
|
|
2038
|
-
|
|
2039
|
-
await
|
|
2948
|
+
jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
|
|
2949
|
+
await ensureQueueOnce(job2.name);
|
|
2040
2950
|
await boss.schedule(
|
|
2041
2951
|
job2.name,
|
|
2042
2952
|
job2.cronExpression,
|
|
2043
2953
|
{},
|
|
2044
2954
|
getDefaultJobOptions(job2.options)
|
|
2045
2955
|
);
|
|
2046
|
-
|
|
2956
|
+
jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
|
|
2047
2957
|
}
|
|
2048
|
-
async function queueRunOnceJob(boss, job2) {
|
|
2958
|
+
async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
|
|
2049
2959
|
if (!job2.runOnce) {
|
|
2050
2960
|
return;
|
|
2051
2961
|
}
|
|
2052
|
-
|
|
2053
|
-
await
|
|
2962
|
+
jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
|
|
2963
|
+
await ensureQueueOnce(job2.name);
|
|
2054
2964
|
await boss.send(
|
|
2055
2965
|
job2.name,
|
|
2056
2966
|
{},
|
|
@@ -2059,7 +2969,7 @@ async function queueRunOnceJob(boss, job2) {
|
|
|
2059
2969
|
singletonKey: `runOnce:${job2.name}`
|
|
2060
2970
|
}
|
|
2061
2971
|
);
|
|
2062
|
-
|
|
2972
|
+
jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
|
|
2063
2973
|
}
|
|
2064
2974
|
async function registerJob(job2) {
|
|
2065
2975
|
const boss = getBoss();
|
|
@@ -2067,15 +2977,16 @@ async function registerJob(job2) {
|
|
|
2067
2977
|
throw new Error("pg-boss not initialized");
|
|
2068
2978
|
}
|
|
2069
2979
|
const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
|
|
2070
|
-
|
|
2980
|
+
jobLogger3.debug(`Registering job: ${job2.name}`, {
|
|
2071
2981
|
queueName,
|
|
2072
2982
|
subscribedEvent: job2.subscribedEvent
|
|
2073
2983
|
});
|
|
2074
|
-
|
|
2984
|
+
const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
|
|
2985
|
+
await registerWorker(boss, job2, queueName, ensureQueueOnce);
|
|
2075
2986
|
connectEventToQueue(boss, job2, queueName);
|
|
2076
|
-
await registerCronSchedule(boss, job2);
|
|
2077
|
-
await queueRunOnceJob(boss, job2);
|
|
2078
|
-
|
|
2987
|
+
await registerCronSchedule(boss, job2, ensureQueueOnce);
|
|
2988
|
+
await queueRunOnceJob(boss, job2, ensureQueueOnce);
|
|
2989
|
+
jobLogger3.debug(`Job registered: ${job2.name}`);
|
|
2079
2990
|
}
|
|
2080
2991
|
var wsLogger = logger.child("@spfn/core:ws");
|
|
2081
2992
|
function isOriginAllowed(origin, allowList) {
|
|
@@ -2406,6 +3317,50 @@ function readPort(value) {
|
|
|
2406
3317
|
const port = Number(value);
|
|
2407
3318
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
2408
3319
|
}
|
|
3320
|
+
logger.child("@spfn/core:env-registry");
|
|
3321
|
+
function validateAllEnv(registries) {
|
|
3322
|
+
const errors = [];
|
|
3323
|
+
const warnings = [];
|
|
3324
|
+
for (const registry of registries) {
|
|
3325
|
+
const result = registry.validateAll();
|
|
3326
|
+
errors.push(...result.errors);
|
|
3327
|
+
warnings.push(...result.warnings);
|
|
3328
|
+
}
|
|
3329
|
+
return {
|
|
3330
|
+
valid: errors.length === 0,
|
|
3331
|
+
errors,
|
|
3332
|
+
warnings
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// src/server/env-boot-check.ts
|
|
3337
|
+
function runEnvBootCheck(config) {
|
|
3338
|
+
const result = validateAllEnv([registry, ...config.env?.registries ?? []]);
|
|
3339
|
+
const errors = uniqueByMessage(result.errors);
|
|
3340
|
+
const warnings = uniqueByMessage(result.warnings);
|
|
3341
|
+
for (const warning of warnings) {
|
|
3342
|
+
serverLogger.warn(`Environment: ${warning.message}`);
|
|
3343
|
+
}
|
|
3344
|
+
if (errors.length > 0) {
|
|
3345
|
+
serverLogger.error(
|
|
3346
|
+
`Environment validation failed (${errors.length}):
|
|
3347
|
+
` + errors.map((error) => ` - ${error.message}`).join("\n")
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
3351
|
+
}
|
|
3352
|
+
function uniqueByMessage(entries) {
|
|
3353
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3354
|
+
return entries.filter((entry) => {
|
|
3355
|
+
const id = `${entry.key}
|
|
3356
|
+
${entry.message}`;
|
|
3357
|
+
if (seen.has(id)) {
|
|
3358
|
+
return false;
|
|
3359
|
+
}
|
|
3360
|
+
seen.add(id);
|
|
3361
|
+
return true;
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
2409
3364
|
|
|
2410
3365
|
// src/server/validation.ts
|
|
2411
3366
|
function validateServerConfig(config) {
|
|
@@ -2464,6 +3419,10 @@ var processHandlersRegistered = false;
|
|
|
2464
3419
|
async function startServer(config) {
|
|
2465
3420
|
loadEnv();
|
|
2466
3421
|
const finalConfig = await loadAndMergeConfig(config);
|
|
3422
|
+
if (!runEnvBootCheck(finalConfig).valid) {
|
|
3423
|
+
serverLogger.error("Refusing to start: fix the environment variables listed above.");
|
|
3424
|
+
process.exit(1);
|
|
3425
|
+
}
|
|
2467
3426
|
const { host, port, debug } = finalConfig;
|
|
2468
3427
|
validateServerConfig(finalConfig);
|
|
2469
3428
|
if (!host || !port) {
|
|
@@ -2954,6 +3913,9 @@ function createServerlessApp(config) {
|
|
|
2954
3913
|
}
|
|
2955
3914
|
async function buildServerlessApp(rawConfig) {
|
|
2956
3915
|
loadEnv();
|
|
3916
|
+
if (!runEnvBootCheck(rawConfig ?? {}).valid) {
|
|
3917
|
+
throw new Error("Environment validation failed \u2014 the missing or invalid keys are logged above.");
|
|
3918
|
+
}
|
|
2957
3919
|
const config = withServerlessDefaults(rawConfig);
|
|
2958
3920
|
warnIfJobsConfigured(config);
|
|
2959
3921
|
await initInfrastructure(config);
|
|
@@ -3104,6 +4066,20 @@ var ServerConfigBuilder = class {
|
|
|
3104
4066
|
/**
|
|
3105
4067
|
* Register background jobs router
|
|
3106
4068
|
*
|
|
4069
|
+
* May be called once per domain router: repeated calls **merge** into one
|
|
4070
|
+
* router, the way `.routes()` composes. Calling it several times used to
|
|
4071
|
+
* keep only the last router and register nothing else, without warning.
|
|
4072
|
+
*
|
|
4073
|
+
* The pg-boss `config` is per server, so pass it on one call only.
|
|
4074
|
+
*
|
|
4075
|
+
* Two names can collide, and both fail loudly:
|
|
4076
|
+
* - a router **key** present in two calls throws here, at config time;
|
|
4077
|
+
* - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
|
|
4078
|
+
* `registerJobs()` at boot, before any queue or worker is created.
|
|
4079
|
+
*
|
|
4080
|
+
* @throws if a router key was already registered by an earlier call
|
|
4081
|
+
* @throws if `config` is given when an earlier call already gave one
|
|
4082
|
+
*
|
|
3107
4083
|
* @example
|
|
3108
4084
|
* ```typescript
|
|
3109
4085
|
* import { job, defineJobRouter } from '@spfn/core/job';
|
|
@@ -3116,12 +4092,18 @@ var ServerConfigBuilder = class {
|
|
|
3116
4092
|
*
|
|
3117
4093
|
* export default defineServerConfig()
|
|
3118
4094
|
* .routes(appRouter)
|
|
3119
|
-
* .jobs(jobRouter)
|
|
4095
|
+
* .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
|
|
4096
|
+
* .jobs(billingJobRouter) // merged, not replaced
|
|
3120
4097
|
* .build();
|
|
3121
4098
|
* ```
|
|
3122
4099
|
*/
|
|
3123
4100
|
jobs(router, config) {
|
|
3124
|
-
this.config.
|
|
4101
|
+
if (config && this.config.jobsConfig) {
|
|
4102
|
+
throw new Error(
|
|
4103
|
+
"jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
|
|
3125
4107
|
if (config) {
|
|
3126
4108
|
this.config.jobsConfig = config;
|
|
3127
4109
|
}
|
|
@@ -3231,6 +4213,15 @@ var ServerConfigBuilder = class {
|
|
|
3231
4213
|
this.config.healthCheck = healthCheck;
|
|
3232
4214
|
return this;
|
|
3233
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* Supply the clock used by the built-in `GET /_core/time` capability.
|
|
4218
|
+
* Production servers normally keep the default `Date.now()` clock; this is
|
|
4219
|
+
* exposed so tests can assert an exact wire value without replacing globals.
|
|
4220
|
+
*/
|
|
4221
|
+
serverTime(serverTime) {
|
|
4222
|
+
this.config.serverTime = serverTime;
|
|
4223
|
+
return this;
|
|
4224
|
+
}
|
|
3234
4225
|
/**
|
|
3235
4226
|
* Configure infrastructure initialization
|
|
3236
4227
|
*/
|
|
@@ -3253,6 +4244,25 @@ var ServerConfigBuilder = class {
|
|
|
3253
4244
|
this.config.migrations = migrations;
|
|
3254
4245
|
return this;
|
|
3255
4246
|
}
|
|
4247
|
+
/**
|
|
4248
|
+
* Hand the app's env registries to the boot check
|
|
4249
|
+
*
|
|
4250
|
+
* `@spfn/core`'s own registry is always checked; the registries given here
|
|
4251
|
+
* are checked with it, before the server listens.
|
|
4252
|
+
*
|
|
4253
|
+
* @example
|
|
4254
|
+
* ```typescript
|
|
4255
|
+
* import { envRegistry } from '@/server/config/env.config';
|
|
4256
|
+
*
|
|
4257
|
+
* export default defineServerConfig()
|
|
4258
|
+
* .env({ registries: [envRegistry] })
|
|
4259
|
+
* .build();
|
|
4260
|
+
* ```
|
|
4261
|
+
*/
|
|
4262
|
+
env(env6) {
|
|
4263
|
+
this.config.env = env6;
|
|
4264
|
+
return this;
|
|
4265
|
+
}
|
|
3256
4266
|
/**
|
|
3257
4267
|
* Register workflow router for workflow orchestration
|
|
3258
4268
|
*
|
|
@@ -3327,6 +4337,6 @@ function defineServerConfig() {
|
|
|
3327
4337
|
return new ServerConfigBuilder();
|
|
3328
4338
|
}
|
|
3329
4339
|
|
|
3330
|
-
export { CORE_HEALTH_PATH, CORE_NAMESPACE, PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
4340
|
+
export { CORE_HEALTH_PATH, CORE_NAMESPACE, CORE_TIME_OPERATION_ID, CORE_TIME_PATH, CORE_TIME_ROUTE, PendingMigrationsError, ServerTimeResponseSchema, createCoreTimeRoute, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
3331
4341
|
//# sourceMappingURL=index.js.map
|
|
3332
4342
|
//# sourceMappingURL=index.js.map
|