brustjs 0.1.35-alpha → 0.1.36-alpha

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/runtime/routes.ts CHANGED
@@ -612,15 +612,19 @@ export interface MakeRendererOptions {
612
612
  actions?: EndpointDef[]
613
613
  /** MCP server instance — built once per worker at module top-level. */
614
614
  mcp?: import('./mcp/server.ts').McpServer
615
+ /** Render slots per worker. The `view` SAB is partitioned into this many
616
+ * disjoint sub-views; each render is dispatched with a `slot` index and
617
+ * operates on `view.subarray(slot*sub, slot*sub+sub)`. Default 1 (the whole
618
+ * view → byte-identical to the pre-multi-slot path). */
619
+ slots?: number
615
620
  }
616
621
 
617
622
  export function makeRenderer(
618
623
  routes: FlatRoute[],
619
624
  view: Uint8Array,
620
625
  opts: MakeRendererOptions = {},
621
- ): (envelopeJsonOrLen: number | string) => Promise<number> {
626
+ ): (envelopeJson: string, slot?: number) => Promise<number> {
622
627
  const encoder = new TextEncoder()
623
- const decoder = new TextDecoder()
624
628
  const byRouteId = new Map<number, FlatRoute>()
625
629
  routes.forEach((r, i) => {
626
630
  byRouteId.set(i, r)
@@ -630,28 +634,41 @@ export function makeRenderer(
630
634
  byActionId.set(String(i), e)
631
635
  })
632
636
 
637
+ // Slot count: the SAB is partitioned into `slots` disjoint sub-views. At
638
+ // slots=1 the sub-view is the whole buffer (byte-identical to before).
639
+ const slots = Math.max(1, opts.slots ?? 1)
640
+ const sub = Math.floor(view.length / slots)
641
+
633
642
  // napi shim for the chunk channel. The sabBytes arg is ignored by the
634
- // native fn (Rust reads from the pre-registered BufPtr) the call sites
635
- // still pass it so renderBranchStreaming can be unit-tested against a
636
- // mock that captures the bytes.
643
+ // native fn (Rust reads from the pre-registered BufPtr, offset by slot)
644
+ // the call sites still pass it so renderBranchStreaming can be unit-tested
645
+ // against a mock that captures the bytes. The `slot` arg selects the SAB
646
+ // sub-region Rust reads from.
637
647
  const napi = {
638
- renderChunk: async (workerId: bigint, len: number, _sabBytes: Uint8Array): Promise<void> => {
639
- await (native as any).napiRenderChunk(Number(workerId), len)
648
+ renderChunk: async (
649
+ workerId: bigint,
650
+ slot: number,
651
+ len: number,
652
+ _sabBytes: Uint8Array,
653
+ ): Promise<void> => {
654
+ await (native as any).napiRenderChunk(Number(workerId), slot, len)
640
655
  },
641
656
  renderChunkFinal: async (
642
657
  workerId: bigint,
658
+ slot: number,
643
659
  len: number,
644
660
  _sabBytes: Uint8Array,
645
661
  ): Promise<void> => {
646
- await (native as any).napiRenderChunkFinal(Number(workerId), len)
662
+ await (native as any).napiRenderChunkFinal(Number(workerId), slot, len)
647
663
  },
648
664
  }
649
665
 
650
- return async (envelopeJsonOrLen: number | string): Promise<number> => {
651
- const envelopeJson =
652
- typeof envelopeJsonOrLen === 'number'
653
- ? decoder.decode(view.subarray(0, envelopeJsonOrLen))
654
- : envelopeJsonOrLen
666
+ return async (envelopeJson: string, slot = 0): Promise<number> => {
667
+ // The disjoint SAB sub-view this render owns. At slots=1, slotView === the
668
+ // whole view (offset 0, full length) so the K=1 path is byte-identical.
669
+ // The request always arrives as an INLINE JSON string (SAB-request is closed
670
+ // — see the dispatch module doc); the SAB is used only for the RESPONSE.
671
+ const slotView = slots === 1 ? view : view.subarray(slot * sub, slot * sub + sub)
655
672
  const call = JSON.parse(envelopeJson) as RouteCall
656
673
  const wid = opts.getWorkerId?.() ?? 0
657
674
  const workerId = BigInt(wid)
@@ -660,11 +677,18 @@ export function makeRenderer(
660
677
  const flat = byRouteId.get(call.route_id)
661
678
  if (!flat) {
662
679
  console.error(`[brust] unknown route_id=${call.route_id} for path=${call.path}`)
663
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
664
- status: 404,
665
- contentType: 'text/plain; charset=utf-8',
666
- body: 'not found',
667
- })
680
+ await emitSingleChunkResponse(
681
+ slotView,
682
+ napi,
683
+ workerId,
684
+ encoder,
685
+ {
686
+ status: 404,
687
+ contentType: 'text/plain; charset=utf-8',
688
+ body: 'not found',
689
+ },
690
+ slot,
691
+ )
668
692
  return 0
669
693
  }
670
694
  // Inject the permanently-unaborted signal — non-SSE routes don't
@@ -695,7 +719,7 @@ export function makeRenderer(
695
719
  // FAST LANE: single-chunk error. Works for both React (big dispatch
696
720
  // reads the SAB via its fast-lane arm) and native routes (which take
697
721
  // the channel-free dispatch_single_chunk).
698
- return packSingleChunkResponse(view, encoder, {
722
+ return packSingleChunkResponse(slotView, encoder, {
699
723
  status: 500,
700
724
  contentType: 'text/html; charset=utf-8',
701
725
  body: 'internal error',
@@ -705,7 +729,7 @@ export function makeRenderer(
705
729
  if (verdict._brustStream !== STREAM_MARKER) {
706
730
  // Middleware short-circuited with a concrete response. FAST LANE —
707
731
  // single-chunk; same dual-dispatch safety as the error path above.
708
- return packSingleChunkResponse(view, encoder, {
732
+ return packSingleChunkResponse(slotView, encoder, {
709
733
  status: verdict.status,
710
734
  contentType: verdict.contentType ?? 'text/html; charset=utf-8',
711
735
  body: verdict.body,
@@ -750,7 +774,7 @@ export function makeRenderer(
750
774
  console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
751
775
  // FAST LANE: native routes take dispatch_single_chunk (no chunk
752
776
  // channel), so every native fallback MUST pack + return a length.
753
- return packSingleChunkResponse(view, encoder, {
777
+ return packSingleChunkResponse(slotView, encoder, {
754
778
  status: 500,
755
779
  contentType: 'text/html; charset=utf-8',
756
780
  body: 'internal error',
@@ -762,7 +786,7 @@ export function makeRenderer(
762
786
  const verdict = chainResult.verdict
763
787
  if (!verdict.render) {
764
788
  // redirect — no template render; fast-lane packed response with Location.
765
- return packSingleChunkResponse(view, encoder, {
789
+ return packSingleChunkResponse(slotView, encoder, {
766
790
  status: verdict.status,
767
791
  contentType: 'text/html; charset=utf-8',
768
792
  body: '',
@@ -828,24 +852,25 @@ export function makeRenderer(
828
852
  const finalBytes = encoder.encode(JSON.stringify(ctx))
829
853
  // The original size check guarded the pre-island bytes; the merged
830
854
  // context (with island props + component html) can be larger. Re-check on finalBytes.
831
- if (finalBytes.length > view.length) {
832
- return packSingleChunkResponse(view, encoder, {
855
+ if (finalBytes.length > slotView.length) {
856
+ return packSingleChunkResponse(slotView, encoder, {
833
857
  status: 413,
834
858
  contentType: 'text/plain; charset=utf-8',
835
859
  body: 'loader data too large for SAB',
836
860
  })
837
861
  }
838
- view.set(finalBytes, 0)
862
+ slotView.set(finalBytes, 0)
839
863
  try {
840
864
  return (native as any).napiRenderJinja(
841
865
  Number(workerId),
866
+ slot,
842
867
  finalBytes.length,
843
868
  flat.nativeTemplate,
844
869
  renderStatus,
845
870
  )
846
871
  } catch (err) {
847
872
  console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
848
- return packSingleChunkResponse(view, encoder, {
873
+ return packSingleChunkResponse(slotView, encoder, {
849
874
  status: 500,
850
875
  contentType: 'text/html; charset=utf-8',
851
876
  body: 'internal error',
@@ -853,14 +878,14 @@ export function makeRenderer(
853
878
  }
854
879
  }
855
880
  const dataBytes = encoder.encode(json)
856
- if (dataBytes.length > view.length) {
857
- return packSingleChunkResponse(view, encoder, {
881
+ if (dataBytes.length > slotView.length) {
882
+ return packSingleChunkResponse(slotView, encoder, {
858
883
  status: 413,
859
884
  contentType: 'text/plain; charset=utf-8',
860
885
  body: 'loader data too large for SAB',
861
886
  })
862
887
  }
863
- view.set(dataBytes, 0)
888
+ slotView.set(dataBytes, 0)
864
889
  try {
865
890
  // FAST LANE: napiRenderJinja is a SYNC napi call — renders Rust-side,
866
891
  // writes the framed response into the SAB, and returns its length
@@ -868,13 +893,14 @@ export function makeRenderer(
868
893
  // fast-lane arm reads the SAB directly (no chunk channel).
869
894
  return (native as any).napiRenderJinja(
870
895
  Number(workerId),
896
+ slot,
871
897
  dataBytes.length,
872
898
  flat.nativeTemplate,
873
899
  renderStatus,
874
900
  )
875
901
  } catch (err) {
876
902
  console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
877
- return packSingleChunkResponse(view, encoder, {
903
+ return packSingleChunkResponse(slotView, encoder, {
878
904
  status: 500,
879
905
  contentType: 'text/html; charset=utf-8',
880
906
  body: 'internal error',
@@ -900,16 +926,24 @@ export function makeRenderer(
900
926
  // bind throw. Shape matches the legacy "internal error" path so
901
927
  // existing integration tests stay green.
902
928
  console.error(`[brust] render setup failed:`, err)
903
- return await emitSingleChunkResponse(view, napi, workerId, encoder, {
904
- status: 500,
905
- contentType: 'text/html; charset=utf-8',
906
- body: 'internal error',
907
- })
929
+ return await emitSingleChunkResponse(
930
+ slotView,
931
+ napi,
932
+ workerId,
933
+ encoder,
934
+ {
935
+ status: 500,
936
+ contentType: 'text/html; charset=utf-8',
937
+ body: 'internal error',
938
+ },
939
+ slot,
940
+ )
908
941
  }
909
942
  const storeSnapshot = collectSnapshot()
910
943
  await renderBranchStreaming({
911
944
  element,
912
- view,
945
+ view: slotView,
946
+ slot,
913
947
  workerId,
914
948
  napi,
915
949
  errorBoundary,
@@ -923,22 +957,22 @@ export function makeRenderer(
923
957
  })
924
958
  }
925
959
  if (call.kind === 'navigation') {
926
- await navigationBranch(call, byRouteId, view, encoder, opts.getWorkerId)
960
+ await navigationBranch(call, byRouteId, slotView, encoder, opts.getWorkerId, slot)
927
961
  return 0
928
962
  }
929
963
  if (call.kind === 'action') {
930
964
  // FAST LANE: pack the framed response into the SAB and return its length.
931
965
  // Rust reads it directly after the Promise settles — no chunk channel.
932
966
  const resp = await dispatchAction(call, byActionId)
933
- return packSingleChunkResponse(view, encoder, resp)
967
+ return packSingleChunkResponse(slotView, encoder, resp)
934
968
  }
935
969
  if (call.kind === 'mcp') {
936
970
  const resp = await mcpBranchToResponse(call, opts.mcp)
937
- return await emitSingleChunkResponse(view, napi, workerId, encoder, resp)
971
+ return await emitSingleChunkResponse(slotView, napi, workerId, encoder, resp, slot)
938
972
  }
939
973
  if (call.kind === 'sse') {
940
974
  try {
941
- await sseBranch(call, view, encoder, routes)
975
+ await sseBranch(call, slotView, encoder, routes)
942
976
  } catch (err) {
943
977
  // Setup-time errors only (BigInt coerce, dynamic import resolve,
944
978
  // napi shim build). Once handleSseStream has started streaming,
@@ -951,7 +985,7 @@ export function makeRenderer(
951
985
  }
952
986
  if (call.kind === 'ws') {
953
987
  try {
954
- await wsBranch(call, view, encoder, routes)
988
+ await wsBranch(call, slotView, encoder, routes)
955
989
  } catch (err) {
956
990
  // Setup-time errors only — same reasoning as sseBranch above.
957
991
  console.error('[brust] wsBranch uncaught:', err)
@@ -963,11 +997,18 @@ export function makeRenderer(
963
997
  // Unknown kind — log and 500. Shouldn't happen unless Rust ships
964
998
  // something out of band.
965
999
  console.error(`[brust] unknown envelope kind in worker:`, (call as { kind?: string }).kind)
966
- return await emitSingleChunkResponse(view, napi, workerId, encoder, {
967
- status: 500,
968
- contentType: 'text/plain; charset=utf-8',
969
- body: 'invalid envelope kind',
970
- })
1000
+ return await emitSingleChunkResponse(
1001
+ slotView,
1002
+ napi,
1003
+ workerId,
1004
+ encoder,
1005
+ {
1006
+ status: 500,
1007
+ contentType: 'text/plain; charset=utf-8',
1008
+ body: 'invalid envelope kind',
1009
+ },
1010
+ slot,
1011
+ )
971
1012
  }
972
1013
  }
973
1014
 
@@ -1013,24 +1054,37 @@ async function navigationBranch(
1013
1054
  view: Uint8Array,
1014
1055
  encoder: TextEncoder,
1015
1056
  getWorkerId: (() => number | null) | undefined,
1057
+ slot = 0,
1016
1058
  ): Promise<void> {
1017
1059
  const workerId = BigInt(getWorkerId?.() ?? 0)
1018
1060
  const napi = {
1019
- renderChunk: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
1020
- await (native as any).napiRenderChunk(Number(wid), len)
1061
+ renderChunk: async (wid: bigint, s: number, len: number, _view: Uint8Array): Promise<void> => {
1062
+ await (native as any).napiRenderChunk(Number(wid), s, len)
1021
1063
  },
1022
- renderChunkFinal: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
1023
- await (native as any).napiRenderChunkFinal(Number(wid), len)
1064
+ renderChunkFinal: async (
1065
+ wid: bigint,
1066
+ s: number,
1067
+ len: number,
1068
+ _view: Uint8Array,
1069
+ ): Promise<void> => {
1070
+ await (native as any).napiRenderChunkFinal(Number(wid), s, len)
1024
1071
  },
1025
1072
  }
1026
1073
 
1027
1074
  const flat = byRouteId.get(call.route_id)
1028
1075
  if (!flat) {
1029
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
1030
- status: 404,
1031
- contentType: 'application/json; charset=utf-8',
1032
- body: '{"error":"not found"}',
1033
- })
1076
+ await emitSingleChunkResponse(
1077
+ view,
1078
+ napi,
1079
+ workerId,
1080
+ encoder,
1081
+ {
1082
+ status: 404,
1083
+ contentType: 'application/json; charset=utf-8',
1084
+ body: '{"error":"not found"}',
1085
+ },
1086
+ slot,
1087
+ )
1034
1088
  return
1035
1089
  }
1036
1090
 
@@ -1059,11 +1113,18 @@ async function navigationBranch(
1059
1113
  navVerdict = (await navChain()) as NavMarkerResponse
1060
1114
  } catch (err) {
1061
1115
  console.error('[brust] navigation middleware threw:', err)
1062
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
1063
- status: 500,
1064
- contentType: 'application/json; charset=utf-8',
1065
- body: '{"error":"middleware threw"}',
1066
- })
1116
+ await emitSingleChunkResponse(
1117
+ view,
1118
+ napi,
1119
+ workerId,
1120
+ encoder,
1121
+ {
1122
+ status: 500,
1123
+ contentType: 'application/json; charset=utf-8',
1124
+ body: '{"error":"middleware threw"}',
1125
+ },
1126
+ slot,
1127
+ )
1067
1128
  return
1068
1129
  }
1069
1130
 
@@ -1071,12 +1132,19 @@ async function navigationBranch(
1071
1132
  // Middleware short-circuited — emit the verdict. Client's non-2xx check
1072
1133
  // triggers the full-reload fallback so the user hits the real route and
1073
1134
  // sees the middleware's challenge page.
1074
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
1075
- status: navVerdict.status,
1076
- contentType: navVerdict.contentType ?? 'application/json; charset=utf-8',
1077
- body: navVerdict.body,
1078
- headers: navVerdict.headers,
1079
- })
1135
+ await emitSingleChunkResponse(
1136
+ view,
1137
+ napi,
1138
+ workerId,
1139
+ encoder,
1140
+ {
1141
+ status: navVerdict.status,
1142
+ contentType: navVerdict.contentType ?? 'application/json; charset=utf-8',
1143
+ body: navVerdict.body,
1144
+ headers: navVerdict.headers,
1145
+ },
1146
+ slot,
1147
+ )
1080
1148
  return
1081
1149
  }
1082
1150
 
@@ -1100,7 +1168,7 @@ async function navigationBranch(
1100
1168
  // full-document native render path).
1101
1169
  let navHeaders: Record<string, string> | undefined
1102
1170
  if (flat.nativeTemplate !== undefined) {
1103
- fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId)
1171
+ fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId, slot)
1104
1172
  } else {
1105
1173
  // Wrap loader run (inside buildRenderElement) + render in one store scope so
1106
1174
  // store reads resolve the per-request instance; collect after render.
@@ -1136,19 +1204,33 @@ async function navigationBranch(
1136
1204
  const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
1137
1205
 
1138
1206
  const body = JSON.stringify({ html: innerHtml, title, store })
1139
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
1140
- status: 200,
1141
- contentType: 'application/json; charset=utf-8',
1142
- body,
1143
- headers: navHeaders,
1144
- })
1207
+ await emitSingleChunkResponse(
1208
+ view,
1209
+ napi,
1210
+ workerId,
1211
+ encoder,
1212
+ {
1213
+ status: 200,
1214
+ contentType: 'application/json; charset=utf-8',
1215
+ body,
1216
+ headers: navHeaders,
1217
+ },
1218
+ slot,
1219
+ )
1145
1220
  } catch (err) {
1146
1221
  console.error('[brust] navigation render failed:', err)
1147
- await emitSingleChunkResponse(view, napi, workerId, encoder, {
1148
- status: 500,
1149
- contentType: 'application/json; charset=utf-8',
1150
- body: '{"error":"render failed"}',
1151
- })
1222
+ await emitSingleChunkResponse(
1223
+ view,
1224
+ napi,
1225
+ workerId,
1226
+ encoder,
1227
+ {
1228
+ status: 500,
1229
+ contentType: 'application/json; charset=utf-8',
1230
+ body: '{"error":"render failed"}',
1231
+ },
1232
+ slot,
1233
+ )
1152
1234
  }
1153
1235
  }
1154
1236
 
@@ -1170,6 +1252,7 @@ async function renderNativeRouteToHtml(
1170
1252
  view: Uint8Array,
1171
1253
  encoder: TextEncoder,
1172
1254
  workerId: bigint,
1255
+ slot = 0,
1173
1256
  ): Promise<string> {
1174
1257
  const templateName = flat.nativeTemplate as string
1175
1258
 
@@ -1211,6 +1294,7 @@ async function renderNativeRouteToHtml(
1211
1294
 
1212
1295
  const len = (native as any).napiRenderJinja(
1213
1296
  Number(workerId),
1297
+ slot,
1214
1298
  bytes.length,
1215
1299
  templateName,
1216
1300
  ) as number
@@ -1331,8 +1415,8 @@ function packSingleChunkResponse(
1331
1415
  async function emitSingleChunkResponse(
1332
1416
  view: Uint8Array,
1333
1417
  napi: {
1334
- renderChunk: (w: bigint, len: number, view: Uint8Array) => Promise<void>
1335
- renderChunkFinal: (w: bigint, len: number, view: Uint8Array) => Promise<void>
1418
+ renderChunk: (w: bigint, slot: number, len: number, view: Uint8Array) => Promise<void>
1419
+ renderChunkFinal: (w: bigint, slot: number, len: number, view: Uint8Array) => Promise<void>
1336
1420
  },
1337
1421
  workerId: bigint,
1338
1422
  encoder: TextEncoder,
@@ -1342,6 +1426,7 @@ async function emitSingleChunkResponse(
1342
1426
  body: string | Uint8Array
1343
1427
  headers?: Record<string, string>
1344
1428
  },
1429
+ slot = 0,
1345
1430
  ): Promise<number> {
1346
1431
  const bodyBytes = typeof resp.body === 'string' ? encoder.encode(resp.body) : resp.body
1347
1432
  const meta = JSON.stringify({
@@ -1372,14 +1457,14 @@ async function emitSingleChunkResponse(
1372
1457
  view[1] = errMetaBytes.length & 0xff
1373
1458
  view.set(errMetaBytes, 2)
1374
1459
  view.set(errBody, 2 + errMetaBytes.length)
1375
- await napi.renderChunkFinal(workerId, errTotal, view)
1460
+ await napi.renderChunkFinal(workerId, slot, errTotal, view)
1376
1461
  return 0
1377
1462
  }
1378
1463
  view[0] = (metaBytes.length >> 8) & 0xff
1379
1464
  view[1] = metaBytes.length & 0xff
1380
1465
  view.set(metaBytes, 2)
1381
1466
  view.set(bodyBytes, 2 + metaBytes.length)
1382
- await napi.renderChunkFinal(workerId, total, view)
1467
+ await napi.renderChunkFinal(workerId, slot, total, view)
1383
1468
  return 0
1384
1469
  }
1385
1470