@beignet/next 0.0.3 → 0.0.4

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/src/index.ts CHANGED
@@ -7,9 +7,16 @@ import {
7
7
  } from "@beignet/core/outbox";
8
8
  import type { AnyPorts, StoragePort } from "@beignet/core/ports";
9
9
  import type {
10
- ProvidedPortsOfList,
10
+ InferProviderPorts,
11
11
  ServiceProvider,
12
12
  } from "@beignet/core/providers";
13
+ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
14
+ import {
15
+ createInlineScheduleRunner,
16
+ type ScheduleDef,
17
+ type ScheduleInstrumentation,
18
+ type StandardSchema,
19
+ } from "@beignet/core/schedules";
13
20
  import type {
14
21
  ContractLike,
15
22
  CreateServerOptions,
@@ -17,6 +24,7 @@ import type {
17
24
  HttpRequestLike,
18
25
  ResolveContract,
19
26
  RouteDef,
27
+ ServerInstance,
20
28
  StandardSchemaV1,
21
29
  } from "@beignet/core/server";
22
30
  import { createServer } from "@beignet/core/server";
@@ -27,11 +35,14 @@ import { createFetchHandler, toWebResponse } from "@beignet/web";
27
35
  * Server types re-exported for Next.js apps.
28
36
  */
29
37
  export type {
38
+ AnyUseCaseLike,
30
39
  CreateServerOptions,
40
+ HandlerRouteDef,
31
41
  RouteDef,
32
42
  RouteGroup,
33
43
  RouteHook,
34
44
  ServerInstance,
45
+ UseCaseRouteDef,
35
46
  } from "@beignet/core/server";
36
47
  /**
37
48
  * Server helpers re-exported for Next.js apps.
@@ -39,9 +50,9 @@ export type {
39
50
  export {
40
51
  contractsFromRoutes,
41
52
  createServer,
53
+ defaultBinderInput,
42
54
  defineRoute,
43
55
  defineRouteGroup,
44
- defineRouteHook,
45
56
  defineRoutes,
46
57
  } from "@beignet/core/server";
47
58
 
@@ -54,7 +65,11 @@ type AnyProvider = ServiceProvider<
54
65
  unknown,
55
66
  // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
56
67
  StandardSchemaV1<any, any>,
57
- AnyPorts
68
+ AnyPorts,
69
+ // biome-ignore lint/suspicious/noExplicitAny: provider context types are erased at this level
70
+ any,
71
+ // biome-ignore lint/suspicious/noExplicitAny: provider service-input types are erased at this level
72
+ any
58
73
  >;
59
74
 
60
75
  /**
@@ -202,7 +217,7 @@ type OutboxDrainLogger = {
202
217
  error(message: string, metadata?: Record<string, unknown>): void;
203
218
  };
204
219
 
205
- type OutboxDrainDevtools = {
220
+ type OutboxDrainInstrumentation = {
206
221
  record(event: {
207
222
  type: "custom";
208
223
  watcher: string;
@@ -220,7 +235,8 @@ type OutboxDrainPorts = {
220
235
  eventBus?: DrainOutboxOptions["eventBus"];
221
236
  jobs?: DrainOutboxOptions["jobs"];
222
237
  logger?: OutboxDrainLogger;
223
- devtools?: OutboxDrainDevtools;
238
+ devtools?: OutboxDrainInstrumentation;
239
+ instrumentation?: OutboxDrainInstrumentation;
224
240
  };
225
241
 
226
242
  type OutboxDrainContext = {
@@ -266,6 +282,59 @@ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
266
282
  headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
267
283
  };
268
284
 
285
+ type ScheduleRouteLogger = {
286
+ error(message: string, metadata?: Record<string, unknown>): void;
287
+ };
288
+
289
+ type ScheduleRoutePorts = {
290
+ logger?: ScheduleRouteLogger;
291
+ devtools?: ScheduleInstrumentation;
292
+ instrumentation?: ScheduleInstrumentation;
293
+ };
294
+
295
+ type ScheduleRouteContext = {
296
+ requestId?: string;
297
+ traceId?: string;
298
+ ports: ScheduleRoutePorts;
299
+ };
300
+
301
+ /**
302
+ * Options for creating a serverless-safe schedule trigger route.
303
+ */
304
+ export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
305
+ /**
306
+ * Beignet Next server that owns app context and ports.
307
+ */
308
+ server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
309
+ /**
310
+ * Registered schedules, usually the `schedules` array from
311
+ * `server/schedules.ts`.
312
+ */
313
+ schedules: readonly ScheduleDef<string, StandardSchema, Ctx>[];
314
+ /**
315
+ * Name of the schedule this route triggers. Unknown names throw when the
316
+ * route module loads so misconfigured routes fail at build or boot time.
317
+ */
318
+ schedule: string;
319
+ /**
320
+ * Shared secret expected in the `Authorization: Bearer <secret>` header.
321
+ *
322
+ * Missing secrets fail closed with a 500 response so deployments do not
323
+ * accidentally expose an unauthenticated trigger route.
324
+ */
325
+ secret?: string;
326
+ /**
327
+ * Run source label recorded on run metadata and devtools events.
328
+ *
329
+ * @default "next-cron-route"
330
+ */
331
+ source?: string;
332
+ /**
333
+ * Additional response headers.
334
+ */
335
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
336
+ };
337
+
269
338
  type UploadRouteContext = {
270
339
  params?:
271
340
  | Promise<Record<string, string | string[] | undefined>>
@@ -275,7 +344,11 @@ type UploadRouteContext = {
275
344
  /**
276
345
  * Beignet server adapted for Next.js route handlers and server components.
277
346
  */
278
- export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
347
+ export interface NextServer<
348
+ Ctx,
349
+ Ports extends AnyPorts = AnyPorts,
350
+ ServiceInput = void,
351
+ > {
279
352
  /**
280
353
  * Catch-all Next.js route handler.
281
354
  */
@@ -294,6 +367,25 @@ export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
294
367
  * Create app context from `next/headers` for Server Components.
295
368
  */
296
369
  createContextFromNext: () => Promise<Ctx>;
370
+ /**
371
+ * Build a fully assembled request context from a framework-neutral request.
372
+ */
373
+ createRequestContext: ServerInstance<
374
+ Ctx,
375
+ Ports,
376
+ ServiceInput
377
+ >["createRequestContext"];
378
+ /**
379
+ * Build a fully assembled service context for schedules, outbox drains,
380
+ * commands, and background work.
381
+ *
382
+ * Requires `context.service` to be declared in `createNextServer(...)`.
383
+ */
384
+ createServiceContext: ServerInstance<
385
+ Ctx,
386
+ Ports,
387
+ ServiceInput
388
+ >["createServiceContext"];
297
389
  /**
298
390
  * Registered contract inputs.
299
391
  */
@@ -683,7 +775,7 @@ export function createUploadRoute(
683
775
  };
684
776
  }
685
777
 
686
- function outboxDrainJson(
778
+ function cronRouteJson(
687
779
  body: unknown,
688
780
  status: number,
689
781
  headers: HeadersInit | undefined,
@@ -694,6 +786,39 @@ function outboxDrainJson(
694
786
  });
695
787
  }
696
788
 
789
+ /**
790
+ * Compare two strings without leaking length or prefix timing.
791
+ *
792
+ * Both values are hashed with SHA-256 so the XOR comparison always runs over
793
+ * fixed-size digests, which keeps the check edge-compatible and constant-time.
794
+ */
795
+ async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
796
+ const encoder = new TextEncoder();
797
+ const [digestA, digestB] = await Promise.all([
798
+ crypto.subtle.digest("SHA-256", encoder.encode(a)),
799
+ crypto.subtle.digest("SHA-256", encoder.encode(b)),
800
+ ]);
801
+ const bytesA = new Uint8Array(digestA);
802
+ const bytesB = new Uint8Array(digestB);
803
+
804
+ let mismatch = 0;
805
+ for (let index = 0; index < bytesA.length; index++) {
806
+ mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0);
807
+ }
808
+
809
+ return mismatch === 0;
810
+ }
811
+
812
+ async function isAuthorizedCronRequest(
813
+ req: Request,
814
+ secret: string,
815
+ ): Promise<boolean> {
816
+ return timingSafeStringEqual(
817
+ req.headers.get("authorization") ?? "",
818
+ `Bearer ${secret}`,
819
+ );
820
+ }
821
+
697
822
  /**
698
823
  * Create Next.js route handlers that drain one durable outbox batch.
699
824
  *
@@ -712,7 +837,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
712
837
  const secret = options.secret;
713
838
 
714
839
  if (!secret) {
715
- return outboxDrainJson(
840
+ return cronRouteJson(
716
841
  {
717
842
  ok: false,
718
843
  error: "CRON_SECRET is not configured.",
@@ -722,12 +847,8 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
722
847
  );
723
848
  }
724
849
 
725
- if (req.headers.get("authorization") !== `Bearer ${secret}`) {
726
- return outboxDrainJson(
727
- { ok: false, error: "Unauthorized" },
728
- 401,
729
- headers,
730
- );
850
+ if (!(await isAuthorizedCronRequest(req, secret))) {
851
+ return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
731
852
  }
732
853
 
733
854
  let ctx: Ctx | undefined;
@@ -743,7 +864,11 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
743
864
  batchSize: options.batchSize,
744
865
  leaseMs: options.leaseMs,
745
866
  retryDelayMs: options.retryDelayMs,
746
- instrumentation: drainCtx.ports.devtools,
867
+ instrumentation: resolveProviderInstrumentationPort(drainCtx.ports),
868
+ instrumentationContext: {
869
+ requestId: drainCtx.requestId,
870
+ traceId: drainCtx.traceId,
871
+ },
747
872
  onError(error, message) {
748
873
  drainCtx.ports.logger?.error("Outbox delivery failed", {
749
874
  error,
@@ -756,7 +881,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
756
881
  });
757
882
 
758
883
  try {
759
- await drainCtx.ports.devtools?.record({
884
+ await resolveProviderInstrumentationPort(drainCtx.ports)?.record({
760
885
  type: "custom",
761
886
  watcher: "outbox",
762
887
  name: "outbox.drain",
@@ -767,12 +892,15 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
767
892
  details: result,
768
893
  });
769
894
  } catch (error) {
770
- drainCtx.ports.logger?.error("Outbox drain devtools recording failed", {
771
- error,
772
- });
895
+ drainCtx.ports.logger?.error(
896
+ "Outbox drain instrumentation recording failed",
897
+ {
898
+ error,
899
+ },
900
+ );
773
901
  }
774
902
 
775
- return outboxDrainJson(
903
+ return cronRouteJson(
776
904
  {
777
905
  ok: true,
778
906
  result,
@@ -783,7 +911,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
783
911
  } catch (error) {
784
912
  ctx?.ports.logger?.error("Outbox drain failed", { error });
785
913
 
786
- return outboxDrainJson(
914
+ return cronRouteJson(
787
915
  {
788
916
  ok: false,
789
917
  error: "Outbox drain failed.",
@@ -800,6 +928,105 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
800
928
  };
801
929
  }
802
930
 
931
+ /**
932
+ * Create Next.js route handlers that trigger one registered schedule.
933
+ *
934
+ * This helper is intended for serverless cron routes. It authenticates the
935
+ * caller with a timing-safe bearer comparison, creates app context, runs the
936
+ * schedule inline, and records `schedule` events through the resolved
937
+ * provider instrumentation port (`ports.instrumentation`, then
938
+ * `ports.devtools`) when one is installed.
939
+ */
940
+ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
941
+ options: CreateScheduleRouteOptions<Ctx>,
942
+ ): {
943
+ GET: (req: Request) => Promise<Response>;
944
+ POST: (req: Request) => Promise<Response>;
945
+ } {
946
+ const schedule = options.schedules.find(
947
+ (candidate) => candidate.name === options.schedule,
948
+ );
949
+
950
+ if (!schedule) {
951
+ const available = options.schedules
952
+ .map((candidate) => candidate.name)
953
+ .sort();
954
+
955
+ throw new Error(
956
+ available.length > 0
957
+ ? `Unknown schedule "${options.schedule}". Available schedules: ${available.join(", ")}.`
958
+ : `Unknown schedule "${options.schedule}". No schedules were passed to createScheduleRoute(...).`,
959
+ );
960
+ }
961
+
962
+ const runScheduleFromRequest = async (req: Request): Promise<Response> => {
963
+ const headers = resolveHeaders(options.headers, req);
964
+ const secret = options.secret;
965
+
966
+ if (!secret) {
967
+ return cronRouteJson(
968
+ {
969
+ ok: false,
970
+ error: "CRON_SECRET is not configured.",
971
+ scheduleName: schedule.name,
972
+ },
973
+ 500,
974
+ headers,
975
+ );
976
+ }
977
+
978
+ if (!(await isAuthorizedCronRequest(req, secret))) {
979
+ return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
980
+ }
981
+
982
+ try {
983
+ const ctx = await options.server.createContextFromNext();
984
+ const runner = createInlineScheduleRunner<Ctx>({
985
+ ctx,
986
+ instrumentation: resolveProviderInstrumentationPort(ctx.ports),
987
+ instrumentationContext: {
988
+ requestId: ctx.requestId,
989
+ traceId: ctx.traceId,
990
+ },
991
+ onError({ error }) {
992
+ ctx.ports.logger?.error("Schedule failed", {
993
+ error,
994
+ scheduleName: schedule.name,
995
+ });
996
+ },
997
+ });
998
+
999
+ await runner.run(schedule, {
1000
+ source: options.source ?? "next-cron-route",
1001
+ });
1002
+
1003
+ return cronRouteJson(
1004
+ {
1005
+ ok: true,
1006
+ scheduleName: schedule.name,
1007
+ },
1008
+ 200,
1009
+ headers,
1010
+ );
1011
+ } catch {
1012
+ return cronRouteJson(
1013
+ {
1014
+ ok: false,
1015
+ error: "Schedule failed.",
1016
+ scheduleName: schedule.name,
1017
+ },
1018
+ 500,
1019
+ headers,
1020
+ );
1021
+ }
1022
+ };
1023
+
1024
+ return {
1025
+ GET: runScheduleFromRequest,
1026
+ POST: runScheduleFromRequest,
1027
+ };
1028
+ }
1029
+
803
1030
  /**
804
1031
  * Create a Beignet server adapted for Next.js.
805
1032
  *
@@ -810,11 +1037,14 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
810
1037
  export async function createNextServer<
811
1038
  Ctx,
812
1039
  Ports extends AnyPorts = AnyPorts,
1040
+ ServiceInput = void,
813
1041
  Routes extends readonly RouteDef<Ctx>[] = readonly RouteDef<Ctx>[],
814
1042
  Providers extends readonly AnyProvider[] = readonly AnyProvider[],
815
1043
  >(
816
- options: CreateServerOptions<Ctx, Ports, Routes, Providers>,
817
- ): Promise<NextServer<Ctx, Ports & ProvidedPortsOfList<Providers>>> {
1044
+ options: CreateServerOptions<Ctx, Ports, ServiceInput, Routes, Providers>,
1045
+ ): Promise<
1046
+ NextServer<Ctx, Ports & InferProviderPorts<Providers>, ServiceInput>
1047
+ > {
818
1048
  const runtime = await createServer(options);
819
1049
 
820
1050
  return {
@@ -823,14 +1053,16 @@ export async function createNextServer<
823
1053
  handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
824
1054
  }),
825
1055
  createContextFromNext: async () => {
826
- const { cookies, headers } = await import("next/headers");
1056
+ // "next" ships no package exports map, so Node-style ESM resolution
1057
+ // (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same.
1058
+ const { cookies, headers } = await import("next/headers.js");
827
1059
  const h = await headers();
828
1060
  const c = await cookies();
829
1061
 
830
1062
  // Minimal Request-like object for Next.js Server Components.
831
1063
  // Note: Server Components don't have a real HTTP URL; this is a synthetic placeholder.
832
1064
  // Do not rely on `url` for routing, security, or network behavior.
833
- // Extended with cookies property for easier access in createContext.
1065
+ // Extended with cookies property for easier access in context factories.
834
1066
  // The json() and text() methods return empty values since there's no actual HTTP request body.
835
1067
  const req: HttpRequestLike & {
836
1068
  cookies?: { get: (key: string) => string | undefined };
@@ -851,11 +1083,10 @@ export async function createNextServer<
851
1083
  },
852
1084
  };
853
1085
 
854
- return options.createContext({
855
- req,
856
- ports: runtime.ports,
857
- });
1086
+ return runtime.createRequestContext(req);
858
1087
  },
1088
+ createRequestContext: runtime.createRequestContext,
1089
+ createServiceContext: runtime.createServiceContext,
859
1090
  contracts: runtime.contracts,
860
1091
  stop: () => runtime.stop(),
861
1092
  ports: runtime.ports,