brustjs 0.1.35-alpha → 0.1.37-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
@@ -12,6 +12,7 @@ import * as native from './index.js'
12
12
  import { renderBranchStreaming } from './render/stream.ts'
13
13
  import { runInStoreContext, collectSnapshot } from './store/server-context.ts'
14
14
  import { buildStoreScripts } from './render/inject-store.ts'
15
+ import { getCssHrefsForRoute } from './css.ts'
15
16
  import { runInRequestCache } from './loader-cache.ts'
16
17
  import { runInRequestScope, __scope } from './request-context.ts'
17
18
  import {
@@ -612,15 +613,19 @@ export interface MakeRendererOptions {
612
613
  actions?: EndpointDef[]
613
614
  /** MCP server instance — built once per worker at module top-level. */
614
615
  mcp?: import('./mcp/server.ts').McpServer
616
+ /** Render slots per worker. The `view` SAB is partitioned into this many
617
+ * disjoint sub-views; each render is dispatched with a `slot` index and
618
+ * operates on `view.subarray(slot*sub, slot*sub+sub)`. Default 1 (the whole
619
+ * view → byte-identical to the pre-multi-slot path). */
620
+ slots?: number
615
621
  }
616
622
 
617
623
  export function makeRenderer(
618
624
  routes: FlatRoute[],
619
625
  view: Uint8Array,
620
626
  opts: MakeRendererOptions = {},
621
- ): (envelopeJsonOrLen: number | string) => Promise<number> {
627
+ ): (envelopeJson: string, slot?: number) => Promise<number> {
622
628
  const encoder = new TextEncoder()
623
- const decoder = new TextDecoder()
624
629
  const byRouteId = new Map<number, FlatRoute>()
625
630
  routes.forEach((r, i) => {
626
631
  byRouteId.set(i, r)
@@ -630,28 +635,41 @@ export function makeRenderer(
630
635
  byActionId.set(String(i), e)
631
636
  })
632
637
 
638
+ // Slot count: the SAB is partitioned into `slots` disjoint sub-views. At
639
+ // slots=1 the sub-view is the whole buffer (byte-identical to before).
640
+ const slots = Math.max(1, opts.slots ?? 1)
641
+ const sub = Math.floor(view.length / slots)
642
+
633
643
  // 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.
644
+ // native fn (Rust reads from the pre-registered BufPtr, offset by slot)
645
+ // the call sites still pass it so renderBranchStreaming can be unit-tested
646
+ // against a mock that captures the bytes. The `slot` arg selects the SAB
647
+ // sub-region Rust reads from.
637
648
  const napi = {
638
- renderChunk: async (workerId: bigint, len: number, _sabBytes: Uint8Array): Promise<void> => {
639
- await (native as any).napiRenderChunk(Number(workerId), len)
649
+ renderChunk: async (
650
+ workerId: bigint,
651
+ slot: number,
652
+ len: number,
653
+ _sabBytes: Uint8Array,
654
+ ): Promise<void> => {
655
+ await (native as any).napiRenderChunk(Number(workerId), slot, len)
640
656
  },
641
657
  renderChunkFinal: async (
642
658
  workerId: bigint,
659
+ slot: number,
643
660
  len: number,
644
661
  _sabBytes: Uint8Array,
645
662
  ): Promise<void> => {
646
- await (native as any).napiRenderChunkFinal(Number(workerId), len)
663
+ await (native as any).napiRenderChunkFinal(Number(workerId), slot, len)
647
664
  },
648
665
  }
649
666
 
650
- return async (envelopeJsonOrLen: number | string): Promise<number> => {
651
- const envelopeJson =
652
- typeof envelopeJsonOrLen === 'number'
653
- ? decoder.decode(view.subarray(0, envelopeJsonOrLen))
654
- : envelopeJsonOrLen
667
+ return async (envelopeJson: string, slot = 0): Promise<number> => {
668
+ // The disjoint SAB sub-view this render owns. At slots=1, slotView === the
669
+ // whole view (offset 0, full length) so the K=1 path is byte-identical.
670
+ // The request always arrives as an INLINE JSON string (SAB-request is closed
671
+ // — see the dispatch module doc); the SAB is used only for the RESPONSE.
672
+ const slotView = slots === 1 ? view : view.subarray(slot * sub, slot * sub + sub)
655
673
  const call = JSON.parse(envelopeJson) as RouteCall
656
674
  const wid = opts.getWorkerId?.() ?? 0
657
675
  const workerId = BigInt(wid)
@@ -660,11 +678,18 @@ export function makeRenderer(
660
678
  const flat = byRouteId.get(call.route_id)
661
679
  if (!flat) {
662
680
  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
- })
681
+ await emitSingleChunkResponse(
682
+ slotView,
683
+ napi,
684
+ workerId,
685
+ encoder,
686
+ {
687
+ status: 404,
688
+ contentType: 'text/plain; charset=utf-8',
689
+ body: 'not found',
690
+ },
691
+ slot,
692
+ )
668
693
  return 0
669
694
  }
670
695
  // Inject the permanently-unaborted signal — non-SSE routes don't
@@ -695,7 +720,7 @@ export function makeRenderer(
695
720
  // FAST LANE: single-chunk error. Works for both React (big dispatch
696
721
  // reads the SAB via its fast-lane arm) and native routes (which take
697
722
  // the channel-free dispatch_single_chunk).
698
- return packSingleChunkResponse(view, encoder, {
723
+ return packSingleChunkResponse(slotView, encoder, {
699
724
  status: 500,
700
725
  contentType: 'text/html; charset=utf-8',
701
726
  body: 'internal error',
@@ -705,7 +730,7 @@ export function makeRenderer(
705
730
  if (verdict._brustStream !== STREAM_MARKER) {
706
731
  // Middleware short-circuited with a concrete response. FAST LANE —
707
732
  // single-chunk; same dual-dispatch safety as the error path above.
708
- return packSingleChunkResponse(view, encoder, {
733
+ return packSingleChunkResponse(slotView, encoder, {
709
734
  status: verdict.status,
710
735
  contentType: verdict.contentType ?? 'text/html; charset=utf-8',
711
736
  body: verdict.body,
@@ -750,7 +775,7 @@ export function makeRenderer(
750
775
  console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
751
776
  // FAST LANE: native routes take dispatch_single_chunk (no chunk
752
777
  // channel), so every native fallback MUST pack + return a length.
753
- return packSingleChunkResponse(view, encoder, {
778
+ return packSingleChunkResponse(slotView, encoder, {
754
779
  status: 500,
755
780
  contentType: 'text/html; charset=utf-8',
756
781
  body: 'internal error',
@@ -762,7 +787,7 @@ export function makeRenderer(
762
787
  const verdict = chainResult.verdict
763
788
  if (!verdict.render) {
764
789
  // redirect — no template render; fast-lane packed response with Location.
765
- return packSingleChunkResponse(view, encoder, {
790
+ return packSingleChunkResponse(slotView, encoder, {
766
791
  status: verdict.status,
767
792
  contentType: 'text/html; charset=utf-8',
768
793
  body: '',
@@ -792,6 +817,21 @@ export function makeRenderer(
792
817
  )
793
818
  }
794
819
  ;(data as Record<string, unknown>).__brust_store__ = buildStoreScripts(storeSnapshot)
820
+ // Component-CSS SSR. Fill the framework-owned
821
+ // `{{ __brust_component_css__ | safe }}` head slot with the route's
822
+ // component-CSS chunk <link>s (from the component-CSS manifest, seeded
823
+ // per-route in the worker). Empty string when the route imports no
824
+ // .module.css / co-located .css, so the slot always resolves.
825
+ if (process.env.BRUST_DEV === '1' && '__brust_component_css__' in data) {
826
+ console.warn(
827
+ `[brust] native loader for "${flat.nativeTemplate}" returned a reserved "__brust_component_css__" key — overwritten by the component-CSS links`,
828
+ )
829
+ }
830
+ ;(data as Record<string, unknown>).__brust_component_css__ = getCssHrefsForRoute(
831
+ flat.fullPath,
832
+ )
833
+ .map((href) => `<link rel="stylesheet" href="${href}"/>`)
834
+ .join('')
795
835
  }
796
836
  const json = JSON.stringify(data)
797
837
  // napiRenderJinja has no headers param — any Set-Cookie staged by a
@@ -828,24 +868,25 @@ export function makeRenderer(
828
868
  const finalBytes = encoder.encode(JSON.stringify(ctx))
829
869
  // The original size check guarded the pre-island bytes; the merged
830
870
  // context (with island props + component html) can be larger. Re-check on finalBytes.
831
- if (finalBytes.length > view.length) {
832
- return packSingleChunkResponse(view, encoder, {
871
+ if (finalBytes.length > slotView.length) {
872
+ return packSingleChunkResponse(slotView, encoder, {
833
873
  status: 413,
834
874
  contentType: 'text/plain; charset=utf-8',
835
875
  body: 'loader data too large for SAB',
836
876
  })
837
877
  }
838
- view.set(finalBytes, 0)
878
+ slotView.set(finalBytes, 0)
839
879
  try {
840
880
  return (native as any).napiRenderJinja(
841
881
  Number(workerId),
882
+ slot,
842
883
  finalBytes.length,
843
884
  flat.nativeTemplate,
844
885
  renderStatus,
845
886
  )
846
887
  } catch (err) {
847
888
  console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
848
- return packSingleChunkResponse(view, encoder, {
889
+ return packSingleChunkResponse(slotView, encoder, {
849
890
  status: 500,
850
891
  contentType: 'text/html; charset=utf-8',
851
892
  body: 'internal error',
@@ -853,14 +894,14 @@ export function makeRenderer(
853
894
  }
854
895
  }
855
896
  const dataBytes = encoder.encode(json)
856
- if (dataBytes.length > view.length) {
857
- return packSingleChunkResponse(view, encoder, {
897
+ if (dataBytes.length > slotView.length) {
898
+ return packSingleChunkResponse(slotView, encoder, {
858
899
  status: 413,
859
900
  contentType: 'text/plain; charset=utf-8',
860
901
  body: 'loader data too large for SAB',
861
902
  })
862
903
  }
863
- view.set(dataBytes, 0)
904
+ slotView.set(dataBytes, 0)
864
905
  try {
865
906
  // FAST LANE: napiRenderJinja is a SYNC napi call — renders Rust-side,
866
907
  // writes the framed response into the SAB, and returns its length
@@ -868,13 +909,14 @@ export function makeRenderer(
868
909
  // fast-lane arm reads the SAB directly (no chunk channel).
869
910
  return (native as any).napiRenderJinja(
870
911
  Number(workerId),
912
+ slot,
871
913
  dataBytes.length,
872
914
  flat.nativeTemplate,
873
915
  renderStatus,
874
916
  )
875
917
  } catch (err) {
876
918
  console.error(`[brust] napiRenderJinja failed for "${flat.nativeTemplate}":`, err)
877
- return packSingleChunkResponse(view, encoder, {
919
+ return packSingleChunkResponse(slotView, encoder, {
878
920
  status: 500,
879
921
  contentType: 'text/html; charset=utf-8',
880
922
  body: 'internal error',
@@ -900,16 +942,24 @@ export function makeRenderer(
900
942
  // bind throw. Shape matches the legacy "internal error" path so
901
943
  // existing integration tests stay green.
902
944
  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
- })
945
+ return await emitSingleChunkResponse(
946
+ slotView,
947
+ napi,
948
+ workerId,
949
+ encoder,
950
+ {
951
+ status: 500,
952
+ contentType: 'text/html; charset=utf-8',
953
+ body: 'internal error',
954
+ },
955
+ slot,
956
+ )
908
957
  }
909
958
  const storeSnapshot = collectSnapshot()
910
959
  await renderBranchStreaming({
911
960
  element,
912
- view,
961
+ view: slotView,
962
+ slot,
913
963
  workerId,
914
964
  napi,
915
965
  errorBoundary,
@@ -923,22 +973,22 @@ export function makeRenderer(
923
973
  })
924
974
  }
925
975
  if (call.kind === 'navigation') {
926
- await navigationBranch(call, byRouteId, view, encoder, opts.getWorkerId)
976
+ await navigationBranch(call, byRouteId, slotView, encoder, opts.getWorkerId, slot)
927
977
  return 0
928
978
  }
929
979
  if (call.kind === 'action') {
930
980
  // FAST LANE: pack the framed response into the SAB and return its length.
931
981
  // Rust reads it directly after the Promise settles — no chunk channel.
932
982
  const resp = await dispatchAction(call, byActionId)
933
- return packSingleChunkResponse(view, encoder, resp)
983
+ return packSingleChunkResponse(slotView, encoder, resp)
934
984
  }
935
985
  if (call.kind === 'mcp') {
936
986
  const resp = await mcpBranchToResponse(call, opts.mcp)
937
- return await emitSingleChunkResponse(view, napi, workerId, encoder, resp)
987
+ return await emitSingleChunkResponse(slotView, napi, workerId, encoder, resp, slot)
938
988
  }
939
989
  if (call.kind === 'sse') {
940
990
  try {
941
- await sseBranch(call, view, encoder, routes)
991
+ await sseBranch(call, slotView, encoder, routes)
942
992
  } catch (err) {
943
993
  // Setup-time errors only (BigInt coerce, dynamic import resolve,
944
994
  // napi shim build). Once handleSseStream has started streaming,
@@ -951,7 +1001,7 @@ export function makeRenderer(
951
1001
  }
952
1002
  if (call.kind === 'ws') {
953
1003
  try {
954
- await wsBranch(call, view, encoder, routes)
1004
+ await wsBranch(call, slotView, encoder, routes)
955
1005
  } catch (err) {
956
1006
  // Setup-time errors only — same reasoning as sseBranch above.
957
1007
  console.error('[brust] wsBranch uncaught:', err)
@@ -963,11 +1013,18 @@ export function makeRenderer(
963
1013
  // Unknown kind — log and 500. Shouldn't happen unless Rust ships
964
1014
  // something out of band.
965
1015
  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
- })
1016
+ return await emitSingleChunkResponse(
1017
+ slotView,
1018
+ napi,
1019
+ workerId,
1020
+ encoder,
1021
+ {
1022
+ status: 500,
1023
+ contentType: 'text/plain; charset=utf-8',
1024
+ body: 'invalid envelope kind',
1025
+ },
1026
+ slot,
1027
+ )
971
1028
  }
972
1029
  }
973
1030
 
@@ -1013,24 +1070,37 @@ async function navigationBranch(
1013
1070
  view: Uint8Array,
1014
1071
  encoder: TextEncoder,
1015
1072
  getWorkerId: (() => number | null) | undefined,
1073
+ slot = 0,
1016
1074
  ): Promise<void> {
1017
1075
  const workerId = BigInt(getWorkerId?.() ?? 0)
1018
1076
  const napi = {
1019
- renderChunk: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
1020
- await (native as any).napiRenderChunk(Number(wid), len)
1077
+ renderChunk: async (wid: bigint, s: number, len: number, _view: Uint8Array): Promise<void> => {
1078
+ await (native as any).napiRenderChunk(Number(wid), s, len)
1021
1079
  },
1022
- renderChunkFinal: async (wid: bigint, len: number, _view: Uint8Array): Promise<void> => {
1023
- await (native as any).napiRenderChunkFinal(Number(wid), len)
1080
+ renderChunkFinal: async (
1081
+ wid: bigint,
1082
+ s: number,
1083
+ len: number,
1084
+ _view: Uint8Array,
1085
+ ): Promise<void> => {
1086
+ await (native as any).napiRenderChunkFinal(Number(wid), s, len)
1024
1087
  },
1025
1088
  }
1026
1089
 
1027
1090
  const flat = byRouteId.get(call.route_id)
1028
1091
  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
- })
1092
+ await emitSingleChunkResponse(
1093
+ view,
1094
+ napi,
1095
+ workerId,
1096
+ encoder,
1097
+ {
1098
+ status: 404,
1099
+ contentType: 'application/json; charset=utf-8',
1100
+ body: '{"error":"not found"}',
1101
+ },
1102
+ slot,
1103
+ )
1034
1104
  return
1035
1105
  }
1036
1106
 
@@ -1059,11 +1129,18 @@ async function navigationBranch(
1059
1129
  navVerdict = (await navChain()) as NavMarkerResponse
1060
1130
  } catch (err) {
1061
1131
  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
- })
1132
+ await emitSingleChunkResponse(
1133
+ view,
1134
+ napi,
1135
+ workerId,
1136
+ encoder,
1137
+ {
1138
+ status: 500,
1139
+ contentType: 'application/json; charset=utf-8',
1140
+ body: '{"error":"middleware threw"}',
1141
+ },
1142
+ slot,
1143
+ )
1067
1144
  return
1068
1145
  }
1069
1146
 
@@ -1071,12 +1148,19 @@ async function navigationBranch(
1071
1148
  // Middleware short-circuited — emit the verdict. Client's non-2xx check
1072
1149
  // triggers the full-reload fallback so the user hits the real route and
1073
1150
  // 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
- })
1151
+ await emitSingleChunkResponse(
1152
+ view,
1153
+ napi,
1154
+ workerId,
1155
+ encoder,
1156
+ {
1157
+ status: navVerdict.status,
1158
+ contentType: navVerdict.contentType ?? 'application/json; charset=utf-8',
1159
+ body: navVerdict.body,
1160
+ headers: navVerdict.headers,
1161
+ },
1162
+ slot,
1163
+ )
1080
1164
  return
1081
1165
  }
1082
1166
 
@@ -1100,7 +1184,7 @@ async function navigationBranch(
1100
1184
  // full-document native render path).
1101
1185
  let navHeaders: Record<string, string> | undefined
1102
1186
  if (flat.nativeTemplate !== undefined) {
1103
- fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId)
1187
+ fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId, slot)
1104
1188
  } else {
1105
1189
  // Wrap loader run (inside buildRenderElement) + render in one store scope so
1106
1190
  // store reads resolve the per-request instance; collect after render.
@@ -1136,19 +1220,33 @@ async function navigationBranch(
1136
1220
  const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
1137
1221
 
1138
1222
  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
- })
1223
+ await emitSingleChunkResponse(
1224
+ view,
1225
+ napi,
1226
+ workerId,
1227
+ encoder,
1228
+ {
1229
+ status: 200,
1230
+ contentType: 'application/json; charset=utf-8',
1231
+ body,
1232
+ headers: navHeaders,
1233
+ },
1234
+ slot,
1235
+ )
1145
1236
  } catch (err) {
1146
1237
  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
- })
1238
+ await emitSingleChunkResponse(
1239
+ view,
1240
+ napi,
1241
+ workerId,
1242
+ encoder,
1243
+ {
1244
+ status: 500,
1245
+ contentType: 'application/json; charset=utf-8',
1246
+ body: '{"error":"render failed"}',
1247
+ },
1248
+ slot,
1249
+ )
1152
1250
  }
1153
1251
  }
1154
1252
 
@@ -1170,6 +1268,7 @@ async function renderNativeRouteToHtml(
1170
1268
  view: Uint8Array,
1171
1269
  encoder: TextEncoder,
1172
1270
  workerId: bigint,
1271
+ slot = 0,
1173
1272
  ): Promise<string> {
1174
1273
  const templateName = flat.nativeTemplate as string
1175
1274
 
@@ -1211,6 +1310,7 @@ async function renderNativeRouteToHtml(
1211
1310
 
1212
1311
  const len = (native as any).napiRenderJinja(
1213
1312
  Number(workerId),
1313
+ slot,
1214
1314
  bytes.length,
1215
1315
  templateName,
1216
1316
  ) as number
@@ -1331,8 +1431,8 @@ function packSingleChunkResponse(
1331
1431
  async function emitSingleChunkResponse(
1332
1432
  view: Uint8Array,
1333
1433
  napi: {
1334
- renderChunk: (w: bigint, len: number, view: Uint8Array) => Promise<void>
1335
- renderChunkFinal: (w: bigint, len: number, view: Uint8Array) => Promise<void>
1434
+ renderChunk: (w: bigint, slot: number, len: number, view: Uint8Array) => Promise<void>
1435
+ renderChunkFinal: (w: bigint, slot: number, len: number, view: Uint8Array) => Promise<void>
1336
1436
  },
1337
1437
  workerId: bigint,
1338
1438
  encoder: TextEncoder,
@@ -1342,6 +1442,7 @@ async function emitSingleChunkResponse(
1342
1442
  body: string | Uint8Array
1343
1443
  headers?: Record<string, string>
1344
1444
  },
1445
+ slot = 0,
1345
1446
  ): Promise<number> {
1346
1447
  const bodyBytes = typeof resp.body === 'string' ? encoder.encode(resp.body) : resp.body
1347
1448
  const meta = JSON.stringify({
@@ -1372,14 +1473,14 @@ async function emitSingleChunkResponse(
1372
1473
  view[1] = errMetaBytes.length & 0xff
1373
1474
  view.set(errMetaBytes, 2)
1374
1475
  view.set(errBody, 2 + errMetaBytes.length)
1375
- await napi.renderChunkFinal(workerId, errTotal, view)
1476
+ await napi.renderChunkFinal(workerId, slot, errTotal, view)
1376
1477
  return 0
1377
1478
  }
1378
1479
  view[0] = (metaBytes.length >> 8) & 0xff
1379
1480
  view[1] = metaBytes.length & 0xff
1380
1481
  view.set(metaBytes, 2)
1381
1482
  view.set(bodyBytes, 2 + metaBytes.length)
1382
- await napi.renderChunkFinal(workerId, total, view)
1483
+ await napi.renderChunkFinal(workerId, slot, total, view)
1383
1484
  return 0
1384
1485
  }
1385
1486