@brust/core 2.0.0-alpha.35 → 2.0.0-alpha.36

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 CHANGED
@@ -131,7 +131,8 @@ A loader or job call that has not settled within `BRUST_CALL_TIMEOUT_MS` answers
131
131
  slot stays claimed until the call settles (the late result is discarded), so a stuck call costs one
132
132
  slot, never a frozen worker. Waiting for a free slot is the separate 503 "all workers busy".
133
133
  `GET /_brust/cache/stats` reports L1/job cache counters plus `loader_calls` / `job_calls` /
134
- `timed_out_calls`.
134
+ `timed_out_calls`. It is off by default (404): set `[server] stats` to `"public"` or `"token"` (`stats_token = "env:NAME"`)
135
+ to read it; `brust dev` serves it.
135
136
 
136
137
  ## Worker wire (contract 7, `crates/brust-server/src/protocol.rs`)
137
138
 
package/native/index.d.ts CHANGED
@@ -263,6 +263,13 @@ export interface StartOptions {
263
263
  l2?: NapiL2Options
264
264
  /** `brust dev` only (L14): a request carrying this token in `x-brust-dev-token` is traced. Absent in production. */
265
265
  devToken?: string
266
+ /** F507: who may read `/_brust/cache/stats`: `"off"` (the default when absent), `"public"` or `"token"`. */
267
+ stats?: string
268
+ /**
269
+ * F507: the secret `stats = "token"` compares `Authorization: Bearer` with (resolved from its env reference by the
270
+ * JS side); never logged. Boot error `stats: …` for a mode and token that do not go together.
271
+ */
272
+ statsToken?: string
266
273
  }
267
274
 
268
275
  /**
package/native/index.js CHANGED
@@ -89,8 +89,8 @@ function requireNative() {
89
89
  try {
90
90
  const binding = require('@brust/native-android-arm64')
91
91
  const bindingPackageVersion = require('@brust/native-android-arm64/package.json').version
92
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
93
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
92
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
93
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
94
94
  }
95
95
  return binding
96
96
  } catch (e) {
@@ -105,8 +105,8 @@ function requireNative() {
105
105
  try {
106
106
  const binding = require('@brust/native-android-arm-eabi')
107
107
  const bindingPackageVersion = require('@brust/native-android-arm-eabi/package.json').version
108
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
109
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
108
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
109
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
110
110
  }
111
111
  return binding
112
112
  } catch (e) {
@@ -126,8 +126,8 @@ function requireNative() {
126
126
  try {
127
127
  const binding = require('@brust/native-win32-x64-gnu')
128
128
  const bindingPackageVersion = require('@brust/native-win32-x64-gnu/package.json').version
129
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
130
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
129
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
130
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
131
131
  }
132
132
  return binding
133
133
  } catch (e) {
@@ -142,8 +142,8 @@ function requireNative() {
142
142
  try {
143
143
  const binding = require('@brust/native-win32-x64-msvc')
144
144
  const bindingPackageVersion = require('@brust/native-win32-x64-msvc/package.json').version
145
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
146
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
145
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
146
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
147
147
  }
148
148
  return binding
149
149
  } catch (e) {
@@ -159,8 +159,8 @@ function requireNative() {
159
159
  try {
160
160
  const binding = require('@brust/native-win32-ia32-msvc')
161
161
  const bindingPackageVersion = require('@brust/native-win32-ia32-msvc/package.json').version
162
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
163
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
162
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
163
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
164
164
  }
165
165
  return binding
166
166
  } catch (e) {
@@ -175,8 +175,8 @@ function requireNative() {
175
175
  try {
176
176
  const binding = require('@brust/native-win32-arm64-msvc')
177
177
  const bindingPackageVersion = require('@brust/native-win32-arm64-msvc/package.json').version
178
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
179
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
178
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
179
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
180
180
  }
181
181
  return binding
182
182
  } catch (e) {
@@ -194,8 +194,8 @@ function requireNative() {
194
194
  try {
195
195
  const binding = require('@brust/native-darwin-universal')
196
196
  const bindingPackageVersion = require('@brust/native-darwin-universal/package.json').version
197
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
198
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
197
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
198
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
199
199
  }
200
200
  return binding
201
201
  } catch (e) {
@@ -210,8 +210,8 @@ function requireNative() {
210
210
  try {
211
211
  const binding = require('@brust/native-darwin-x64')
212
212
  const bindingPackageVersion = require('@brust/native-darwin-x64/package.json').version
213
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
214
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
213
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
214
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
215
215
  }
216
216
  return binding
217
217
  } catch (e) {
@@ -226,8 +226,8 @@ function requireNative() {
226
226
  try {
227
227
  const binding = require('@brust/native-darwin-arm64')
228
228
  const bindingPackageVersion = require('@brust/native-darwin-arm64/package.json').version
229
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
230
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
229
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
230
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
231
231
  }
232
232
  return binding
233
233
  } catch (e) {
@@ -246,8 +246,8 @@ function requireNative() {
246
246
  try {
247
247
  const binding = require('@brust/native-freebsd-x64')
248
248
  const bindingPackageVersion = require('@brust/native-freebsd-x64/package.json').version
249
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
250
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
249
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
250
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
251
251
  }
252
252
  return binding
253
253
  } catch (e) {
@@ -262,8 +262,8 @@ function requireNative() {
262
262
  try {
263
263
  const binding = require('@brust/native-freebsd-arm64')
264
264
  const bindingPackageVersion = require('@brust/native-freebsd-arm64/package.json').version
265
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
266
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
265
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
266
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
267
267
  }
268
268
  return binding
269
269
  } catch (e) {
@@ -283,8 +283,8 @@ function requireNative() {
283
283
  try {
284
284
  const binding = require('@brust/native-linux-x64-musl')
285
285
  const bindingPackageVersion = require('@brust/native-linux-x64-musl/package.json').version
286
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
287
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
286
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
287
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
288
288
  }
289
289
  return binding
290
290
  } catch (e) {
@@ -299,8 +299,8 @@ function requireNative() {
299
299
  try {
300
300
  const binding = require('@brust/native-linux-x64-gnu')
301
301
  const bindingPackageVersion = require('@brust/native-linux-x64-gnu/package.json').version
302
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
303
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
302
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
303
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
304
304
  }
305
305
  return binding
306
306
  } catch (e) {
@@ -317,8 +317,8 @@ function requireNative() {
317
317
  try {
318
318
  const binding = require('@brust/native-linux-arm64-musl')
319
319
  const bindingPackageVersion = require('@brust/native-linux-arm64-musl/package.json').version
320
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
321
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
320
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
321
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
322
322
  }
323
323
  return binding
324
324
  } catch (e) {
@@ -333,8 +333,8 @@ function requireNative() {
333
333
  try {
334
334
  const binding = require('@brust/native-linux-arm64-gnu')
335
335
  const bindingPackageVersion = require('@brust/native-linux-arm64-gnu/package.json').version
336
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
337
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
336
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
337
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
338
338
  }
339
339
  return binding
340
340
  } catch (e) {
@@ -351,8 +351,8 @@ function requireNative() {
351
351
  try {
352
352
  const binding = require('@brust/native-linux-arm-musleabihf')
353
353
  const bindingPackageVersion = require('@brust/native-linux-arm-musleabihf/package.json').version
354
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
355
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
354
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
355
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
356
356
  }
357
357
  return binding
358
358
  } catch (e) {
@@ -367,8 +367,8 @@ function requireNative() {
367
367
  try {
368
368
  const binding = require('@brust/native-linux-arm-gnueabihf')
369
369
  const bindingPackageVersion = require('@brust/native-linux-arm-gnueabihf/package.json').version
370
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
371
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
370
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
371
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
372
372
  }
373
373
  return binding
374
374
  } catch (e) {
@@ -385,8 +385,8 @@ function requireNative() {
385
385
  try {
386
386
  const binding = require('@brust/native-linux-loong64-musl')
387
387
  const bindingPackageVersion = require('@brust/native-linux-loong64-musl/package.json').version
388
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
389
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
388
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
389
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
390
390
  }
391
391
  return binding
392
392
  } catch (e) {
@@ -401,8 +401,8 @@ function requireNative() {
401
401
  try {
402
402
  const binding = require('@brust/native-linux-loong64-gnu')
403
403
  const bindingPackageVersion = require('@brust/native-linux-loong64-gnu/package.json').version
404
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
405
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
404
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
405
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
406
406
  }
407
407
  return binding
408
408
  } catch (e) {
@@ -419,8 +419,8 @@ function requireNative() {
419
419
  try {
420
420
  const binding = require('@brust/native-linux-riscv64-musl')
421
421
  const bindingPackageVersion = require('@brust/native-linux-riscv64-musl/package.json').version
422
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
423
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
422
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
423
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
424
424
  }
425
425
  return binding
426
426
  } catch (e) {
@@ -435,8 +435,8 @@ function requireNative() {
435
435
  try {
436
436
  const binding = require('@brust/native-linux-riscv64-gnu')
437
437
  const bindingPackageVersion = require('@brust/native-linux-riscv64-gnu/package.json').version
438
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
439
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
438
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
439
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
440
440
  }
441
441
  return binding
442
442
  } catch (e) {
@@ -452,8 +452,8 @@ function requireNative() {
452
452
  try {
453
453
  const binding = require('@brust/native-linux-ppc64-gnu')
454
454
  const bindingPackageVersion = require('@brust/native-linux-ppc64-gnu/package.json').version
455
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
456
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
455
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
456
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
457
457
  }
458
458
  return binding
459
459
  } catch (e) {
@@ -468,8 +468,8 @@ function requireNative() {
468
468
  try {
469
469
  const binding = require('@brust/native-linux-s390x-gnu')
470
470
  const bindingPackageVersion = require('@brust/native-linux-s390x-gnu/package.json').version
471
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
472
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
471
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
472
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
473
473
  }
474
474
  return binding
475
475
  } catch (e) {
@@ -488,8 +488,8 @@ function requireNative() {
488
488
  try {
489
489
  const binding = require('@brust/native-openharmony-arm64')
490
490
  const bindingPackageVersion = require('@brust/native-openharmony-arm64/package.json').version
491
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
492
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
491
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
492
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
493
493
  }
494
494
  return binding
495
495
  } catch (e) {
@@ -504,8 +504,8 @@ function requireNative() {
504
504
  try {
505
505
  const binding = require('@brust/native-openharmony-x64')
506
506
  const bindingPackageVersion = require('@brust/native-openharmony-x64/package.json').version
507
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
508
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
507
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
508
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
509
509
  }
510
510
  return binding
511
511
  } catch (e) {
@@ -520,8 +520,8 @@ function requireNative() {
520
520
  try {
521
521
  const binding = require('@brust/native-openharmony-arm')
522
522
  const bindingPackageVersion = require('@brust/native-openharmony-arm/package.json').version
523
- if (bindingPackageVersion !== '2.0.0-alpha.35' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
524
- throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
523
+ if (bindingPackageVersion !== '2.0.0-alpha.36' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
524
+ throw new Error(`Native binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
525
525
  }
526
526
  return binding
527
527
  } catch (e) {
@@ -661,8 +661,8 @@ if (!nativeBinding || forceWasi) {
661
661
  if (!candidateFailed) {
662
662
  if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
663
663
  const bindingPackageVersion = require('@brust/native-wasm32-wasi/package.json').version
664
- if (bindingPackageVersion !== '2.0.0-alpha.35') {
665
- throw new Error(`WASI binding package version mismatch, expected 2.0.0-alpha.35 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
664
+ if (bindingPackageVersion !== '2.0.0-alpha.36') {
665
+ throw new Error(`WASI binding package version mismatch, expected 2.0.0-alpha.36 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
666
666
  }
667
667
  }
668
668
  wasiBinding = require('@brust/native-wasm32-wasi')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/core",
3
- "version": "2.0.0-alpha.35",
3
+ "version": "2.0.0-alpha.36",
4
4
  "description": "brust v2 — React components compiled to Rust-rendered templates",
5
5
  "license": "MIT",
6
6
  "homepage": "https://brust.dev",
@@ -57,7 +57,7 @@
57
57
  ]
58
58
  },
59
59
  "dependencies": {
60
- "@brust/runtime-dom": "2.0.0-alpha.35",
60
+ "@brust/runtime-dom": "2.0.0-alpha.36",
61
61
  "tsconfig-paths": "4.2.0"
62
62
  },
63
63
  "peerDependencies": {
@@ -101,13 +101,13 @@
101
101
  "access": "public"
102
102
  },
103
103
  "optionalDependencies": {
104
- "@brust/native-darwin-x64": "2.0.0-alpha.35",
105
- "@brust/native-darwin-arm64": "2.0.0-alpha.35",
106
- "@brust/native-linux-x64-gnu": "2.0.0-alpha.35",
107
- "@brust/native-linux-arm64-gnu": "2.0.0-alpha.35",
108
- "@brust/native-linux-x64-musl": "2.0.0-alpha.35",
109
- "@brust/native-linux-arm64-musl": "2.0.0-alpha.35",
110
- "@brust/native-win32-x64-msvc": "2.0.0-alpha.35",
111
- "@brust/core-cf": "2.0.0-alpha.35"
104
+ "@brust/native-darwin-x64": "2.0.0-alpha.36",
105
+ "@brust/native-darwin-arm64": "2.0.0-alpha.36",
106
+ "@brust/native-linux-x64-gnu": "2.0.0-alpha.36",
107
+ "@brust/native-linux-arm64-gnu": "2.0.0-alpha.36",
108
+ "@brust/native-linux-x64-musl": "2.0.0-alpha.36",
109
+ "@brust/native-linux-arm64-musl": "2.0.0-alpha.36",
110
+ "@brust/native-win32-x64-msvc": "2.0.0-alpha.36",
111
+ "@brust/core-cf": "2.0.0-alpha.36"
112
112
  }
113
113
  }
@@ -54,11 +54,32 @@ export interface BrowserCtx {
54
54
  clientDir?: string
55
55
  /** F480: load-time writers shipped into an island chunk (F230), by real path: why each is there. A server-only import
56
56
  * reached through one names that chain in `server-only-in-client`. */
57
- loadWriters?: Map<string, string>
57
+ loadWriters?: Map<string, LoadWriter>
58
58
  /** F495 `[server] base_path`: every `/_brust/…` URL a bundle names carries it (`/portal/_brust/…`); absent or `''` = the root. */
59
59
  basePath?: string
60
60
  }
61
61
 
62
+ /** F480/F481: a load writer shipped into island chunks (F230): its path, every island whose chunk carries it, and the modules it is judged to write. */
63
+ export interface LoadWriter {
64
+ rel: string
65
+ islands: string[]
66
+ writes: string[]
67
+ }
68
+
69
+ /** F481: `a, b, c and 2 more`: at most `cap` names, in sorted order, then the rest as a count. */
70
+ export function namedCapped(names: Iterable<string>, cap = 3): string {
71
+ const all = [...new Set(names)].sort()
72
+ return all.length <= cap ? all.join(', ') : `${all.slice(0, cap).join(', ')} and ${all.length - cap} more`
73
+ }
74
+
75
+ /** The sentence a server-only refusal carries for the load writers it reached: every writer and every island, capped. */
76
+ export function loadWriterReason(writers: LoadWriter[]): string {
77
+ const one = (w: LoadWriter) => `${w.rel} is loaded into the React ${w.islands.length > 1 ? 'islands' : 'island'} ${namedCapped(w.islands)} because it is judged to write ${namedCapped(w.writes, 99)} when it loads: F230 load writes`
78
+ const sorted = [...writers].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))
79
+ const shown = sorted.slice(0, 3).map(one).join('; ')
80
+ return sorted.length > 3 ? `${shown}; and ${sorted.length - 3} more load writers` : shown
81
+ }
82
+
62
83
  /** F495: brust's own URL prefix for a build, `/_brust` or `<base>/_brust`. */
63
84
  export const brustRoot = (ctx: { basePath?: string }): string => `${ctx.basePath ?? ''}/_brust`
64
85
 
@@ -186,9 +207,9 @@ function isServerOnly(spec: string, importer: string, ctx: BrowserCtx): boolean
186
207
  * offending import is stubbed so the bundler gets as far as it can; `browserBuild` then throws
187
208
  * `server-only-in-client` naming every `<importer> imports <spec>`. */
188
209
  function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
189
- const writers = ctx.loadWriters ?? new Map<string, string>()
190
- // F480: who first imported each file (real paths), kept only while a load writer ships, to name the writer chain.
191
- const importerOf = new Map<string, string>()
210
+ const writers = ctx.loadWriters ?? new Map<string, LoadWriter>()
211
+ // F480/F481: who imports each file (real paths; every importer, not the first), kept only while a load writer ships, to name the writer chain.
212
+ const importersOf = new Map<string, Set<string>>()
192
213
  const real = (f: string) => {
193
214
  try {
194
215
  return realpathSync(f)
@@ -200,17 +221,28 @@ function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
200
221
  const from = real(ctx.sourceOf.get(importer) ?? importer)
201
222
  try {
202
223
  const file = real(isAbsolute(spec) ? spec : Bun.resolveSync(spec, dirname(from)))
203
- if (!importerOf.has(file)) importerOf.set(file, from)
224
+ let set = importersOf.get(file)
225
+ if (!set) importersOf.set(file, (set = new Set()))
226
+ set.add(from)
204
227
  } catch {}
205
228
  }
229
+ /** Every load writer a module is reached through (a walk up all its importers, stopping at a writer), as the refusal's reason. */
206
230
  const writerChain = (importer: string): string => {
207
231
  const seen = new Set<string>()
208
- for (let f: string | undefined = real(ctx.sourceOf.get(importer) ?? importer); f !== undefined && !seen.has(f); f = importerOf.get(f)) {
232
+ const found: LoadWriter[] = []
233
+ const stack = [real(ctx.sourceOf.get(importer) ?? importer)]
234
+ while (stack.length > 0) {
235
+ const f = stack.pop()!
236
+ if (seen.has(f)) continue
209
237
  seen.add(f)
210
- const why = writers.get(f)
211
- if (why !== undefined) return ` (${why})`
238
+ const w = writers.get(f)
239
+ if (w !== undefined) {
240
+ found.push(w)
241
+ continue
242
+ }
243
+ stack.push(...(importersOf.get(f) ?? []))
212
244
  }
213
- return ''
245
+ return found.length > 0 ? ` (${loadWriterReason(found)})` : ''
214
246
  }
215
247
  return {
216
248
  name: 'brust-server-only',
@@ -0,0 +1,63 @@
1
+ // F482: the newest `compatibility_date` the app's installed workerd runs. `wrangler dev` (and so `brust dev --target
2
+ // cloudflare`) refuses a date newer than that, while the default date is the @brust/core-cf build date, which can be newer.
3
+ // workerd's npm package carries its own answer: `lib/main.js` exports `compatibilityDate` (the date part of its version,
4
+ // `1.YYYYMMDD.n`). Read statically (the package is never imported).
5
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+
8
+ export interface WorkerdDate {
9
+ /** `YYYY-MM-DD`: the newest compatibility date this workerd supports. */
10
+ date: string
11
+ version: string
12
+ /** The package directory it was read from. */
13
+ dir: string
14
+ }
15
+
16
+ /** The directory of package `name` as Node resolves it from `from`: the nearest `node_modules/<name>` up the tree
17
+ * (package managers that nest, like pnpm, keep a package's own dependencies beside its real directory). */
18
+ function findPackage(name: string, from: string): string | null {
19
+ for (let dir = from; ; dir = dirname(dir)) {
20
+ const p = join(dir, 'node_modules', name)
21
+ if (existsSync(join(p, 'package.json'))) return realpathSync(p)
22
+ if (dirname(dir) === dir) return null
23
+ }
24
+ }
25
+
26
+ const readJson = (file: string): { version?: unknown } | null => {
27
+ try {
28
+ return JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /** The compatibility date of the workerd in package directory `dir`, or null when it cannot be told. */
35
+ function dateOf(dir: string): WorkerdDate | null {
36
+ const version = String(readJson(join(dir, 'package.json'))?.version ?? '')
37
+ let main = ''
38
+ try {
39
+ const f = join(dir, 'lib', 'main.js')
40
+ if (statSync(f).isFile()) main = readFileSync(f, 'utf8')
41
+ } catch {}
42
+ const exported = /\bcompatibilityDate\s*=\s*"(\d{4}-\d{2}-\d{2})"/.exec(main)?.[1]
43
+ const v = /^1\.(\d{4})(\d{2})(\d{2})\./.exec(version)
44
+ const date = exported ?? (v ? `${v[1]}-${v[2]}-${v[3]}` : undefined)
45
+ return date ? { date, version, dir } : null
46
+ }
47
+
48
+ /** The workerd the app's wrangler runs: wrangler → miniflare → workerd, each resolved from the real directory of the one
49
+ * before it (so a nested install is the right copy); else a `workerd` the app resolves itself; else null. */
50
+ export function installedWorkerd(appRoot: string): WorkerdDate | null {
51
+ let from = appRoot
52
+ for (const name of ['wrangler', 'miniflare']) {
53
+ const dir = findPackage(name, from)
54
+ if (!dir) {
55
+ from = ''
56
+ break
57
+ }
58
+ from = dir
59
+ }
60
+ const viaWrangler = from ? findPackage('workerd', from) : null
61
+ const dir = viaWrangler ?? findPackage('workerd', appRoot)
62
+ return dir ? dateOf(dir) : null
63
+ }
@@ -14,6 +14,7 @@ import { type CfConfigJson, cfEntrySource } from './cf-entry'
14
14
  import { type CoreCf, resolveCoreCf } from './cf-core'
15
15
  import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type UserWrangler, verifyCacheBindings } from './cf-wrangler'
16
16
  import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
17
+ import { installedWorkerd } from './cf-workerd'
17
18
  import { MANIFEST_VERSION } from './manifest'
18
19
  import { exposePlugin, PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
19
20
  import type { Compiled } from './compile'
@@ -42,9 +43,25 @@ const DEFAULT_CFG: CloudflareBuildConfig = { name: undefined, compatibilityDate:
42
43
 
43
44
  /** Everything that can fail on the app's own input or install, before a byte is written (index.ts rule). */
44
45
  export async function prepareCloudflare(appRoot: string, cfg: CloudflareBuildConfig | null, flags: CfFlags): Promise<CfPrepared> {
45
- const core = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
46
+ const resolved = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
46
47
  const user = await readUserWrangler(appRoot)
47
- const warnings = [...core.warnings]
48
+ const warnings = [...resolved.warnings]
49
+ // F482: the default date is the core's build date, bounded by what the installed workerd runs (wrangler dev refuses a newer
50
+ // one). An explicit date is used as written, with a warning when it outruns workerd. Not resolvable: today's behaviour.
51
+ const workerd = installedWorkerd(appRoot)
52
+ let core = resolved
53
+ if (cfg?.compatibilityDate !== undefined) {
54
+ if (workerd && cfg.compatibilityDate > workerd.date)
55
+ warnings.push(
56
+ `[brust] warning cf-compat-date compatibility_date ${cfg.compatibilityDate} in brust.toml is newer than ${workerd.date}, the newest the installed workerd ${workerd.version} runs: \`wrangler dev\` and \`brust dev --target cloudflare\` will not start; lower it or upgrade wrangler`,
57
+ )
58
+ } else if (workerd && workerd.date < resolved.meta.compatibilityDate) {
59
+ if (workerd.date >= CF_MIN_COMPAT_DATE) core = { ...resolved, meta: { ...resolved.meta, compatibilityDate: workerd.date } }
60
+ else
61
+ warnings.push(
62
+ `[brust] warning cf-compat-date the installed workerd ${workerd.version} runs dates only up to ${workerd.date}, older than ${CF_MIN_COMPAT_DATE}, the oldest brust supports: the build keeps ${resolved.meta.compatibilityDate}, but \`wrangler dev\` will not start; upgrade wrangler`,
63
+ )
64
+ }
48
65
  try {
49
66
  const toml = Bun.file(join(appRoot, 'brust.toml'))
50
67
  if (await toml.exists()) {
@@ -22,7 +22,7 @@ import {
22
22
  streamEntryError,
23
23
  } from '../actions/prefix'
24
24
  import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
25
- import { normalizeBasePath } from '../config'
25
+ import { checkTrailingSlash, normalizeBasePath, type TrailingSlash } from '../config'
26
26
  import { BuildError } from './errors'
27
27
  import { posixOf } from './posix'
28
28
 
@@ -111,6 +111,8 @@ export interface BuildConfig {
111
111
  sourceRoots: string[]
112
112
  /** `[server] base_path` (F495), normalised (`/portal`); `''` = the app is mounted at the root. Baked into every URL brust emits. */
113
113
  basePath: string
114
+ /** `[server] trailing_slash` (F506), baked into the manifest; `strict` (the default) = the path is matched as written. */
115
+ trailingSlash: TrailingSlash
114
116
  }
115
117
 
116
118
  const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -414,10 +416,18 @@ function parseBasePath(file: string, server: unknown): string {
414
416
  return r.value
415
417
  }
416
418
 
419
+ /** `[server] trailing_slash` (F506): the one validator is config.ts's; a bad value is a build error here too. */
420
+ function parseTrailingSlash(file: string, server: unknown): TrailingSlash {
421
+ if (!isTable(server) || server.trailing_slash === undefined) return 'strict'
422
+ const r = checkTrailingSlash(server.trailing_slash)
423
+ if (!r.ok) throw new BuildError('config', `${file}: server.trailing_slash ${r.why}`)
424
+ return r.value
425
+ }
426
+
417
427
  export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
418
428
  const file = join(appRoot, 'brust.toml')
419
429
  const f = Bun.file(file)
420
- if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '' }
430
+ if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [], sourceRoots: [], basePath: '', trailingSlash: 'strict' }
421
431
  let parsed: unknown
422
432
  try {
423
433
  parsed = Bun.TOML.parse(await f.text())
@@ -426,17 +436,18 @@ export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolea
426
436
  }
427
437
  const root = (parsed ?? {}) as Record<string, unknown>
428
438
  const basePath = parseBasePath(file, root.server)
439
+ const trailingSlash = parseTrailingSlash(file, root.server)
429
440
  const actions = parseActions(file, root.actions, o.actionsOnly === true, basePath)
430
441
  const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
431
442
  const cache = await parseCache(file, appRoot, root.cache)
432
443
  const cssGlobal = parseCss(file, root.css)
433
444
  const build = root.build
434
- if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath }
445
+ if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots: [], basePath, trailingSlash }
435
446
  if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
436
447
  const sourceRoots = parseSourceRoots(file, appRoot, (build as Record<string, unknown>).source_roots)
437
448
  const so = (build as Record<string, unknown>).server_only
438
- if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
449
+ if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash }
439
450
  if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
440
451
  throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
441
- return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath }
452
+ return { serverOnly: so.map((p: string) => posixOf(p.trim()).replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash }
442
453
  }
package/src/build/css.ts CHANGED
@@ -224,9 +224,9 @@ export async function buildCss(
224
224
  if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
225
225
  return hit
226
226
  }
227
- // F501: the output dirs of this app that Tailwind must not scan, as app-root-relative patterns (only those under it).
228
- const own = (o.ownOutput ?? []).map((d) => relative(real(appRoot), real(d)).split(sep).join('/')).filter((r) => r !== '' && r !== '..' && !r.startsWith('../') && !isAbsolute(r))
229
- plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, [...new Set(own)]))
227
+ // F501/F505: brust's own output dirs, as real absolute paths: each is the base of its own negation in the scan.
228
+ const own = [...new Set((o.ownOutput ?? []).map(real))]
229
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log, own))
230
230
  if (tw.imports.length > 1)
231
231
  o.log?.(
232
232
  `[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
@@ -130,7 +130,7 @@ export async function runBuild(opts: {
130
130
  process.env.BRUST_BASE_PATH = built.basePath
131
131
  if (opts.actionsOnly)
132
132
  return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
133
- const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath } = built
133
+ const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots, basePath, trailingSlash } = built
134
134
  // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
135
135
  // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
136
136
  const remote =
@@ -198,7 +198,7 @@ export async function runBuild(opts: {
198
198
  rmSync(tmp, { recursive: true, force: true })
199
199
  mkdirSync(tmp, { recursive: true })
200
200
  try {
201
- await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, basePath, actionsRemote: remote?.prefix ?? null, log: opts.log })
201
+ await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, basePath, trailingSlash, actionsRemote: remote?.prefix ?? null, log: opts.log })
202
202
  if (ssgPlan) {
203
203
  // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
204
204
  const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
@@ -243,6 +243,8 @@ async function buildInto(
243
243
  sourceRoots: string[]
244
244
  /** `[server] base_path` (F495), `''` = the root. */
245
245
  basePath: string
246
+ /** `[server] trailing_slash` (F506); `strict` = the default (not written). */
247
+ trailingSlash: import('../config').TrailingSlash
246
248
  /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
247
249
  actionsRemote: string | null
248
250
  /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
@@ -258,7 +260,7 @@ async function buildInto(
258
260
  const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
259
261
  mkdirSync(join(out, 'client'), { recursive: true })
260
262
  // Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
261
- const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log, basePath: o.basePath, ownOutput: [o.dist, join(appRoot, '.brust')] })
263
+ const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log, basePath: o.basePath, ownOutput: [o.dist, join(appRoot, 'dist'), join(appRoot, '.brust')] })
262
264
  for (const c of directCss(entry))
263
265
  o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
264
266
  const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client'), basePath: o.basePath }
@@ -297,8 +299,10 @@ async function buildInto(
297
299
  o.log(`[brust] warning load-writer-in-client ${relPosix(appRoot, w.file)} is imported into the browser chunk of the React island ${c.id}: it changes ${mods} when it loads, and the island reads that module (F230). Its code ships to the browser: keep secrets and server-only code out of it, or write the value in the module that declares it`)
298
300
  // F480: a server-only import reached through this writer names the chain in `server-only-in-client`.
299
301
  ctx.loadWriters ??= new Map()
300
- if (!ctx.loadWriters.has(w.file))
301
- ctx.loadWriters.set(w.file, `${relPosix(appRoot, w.file)} is loaded into the React island ${c.id} because it is judged to write ${mods} when it loads: F230 load writes`)
302
+ const lw = ctx.loadWriters.get(w.file) ?? { rel: relPosix(appRoot, w.file), islands: [], writes: [] }
303
+ if (!lw.islands.includes(c.id)) lw.islands.push(c.id)
304
+ for (const m of mods.split(', ')) if (!lw.writes.includes(m)) lw.writes.push(m)
305
+ ctx.loadWriters.set(w.file, lw)
302
306
  }
303
307
  return [...new Set(out.map((w) => w.file))]
304
308
  }
@@ -359,6 +363,8 @@ async function buildInto(
359
363
  await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers, o.basePath)
360
364
  // F495: absent without a base path, so an app at the root writes the manifest it always did.
361
365
  if (o.basePath !== '') manifest.base_path = o.basePath
366
+ // F506: absent when strict (the default), so an app that sets nothing writes the manifest it always did.
367
+ if (o.trailingSlash !== 'strict') manifest.trailing_slash = o.trailingSlash
362
368
  manifest.actions =
363
369
  o.actions && o.actionsEntry
364
370
  ? { entry: await buildActions(out, o.actionsEntry, css.maps, o.basePath), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
@@ -102,6 +102,8 @@ export interface ManifestJson {
102
102
  actions_only?: true
103
103
  /** F495 `[server] base_path`: set only for an app mounted under a path. The server answers under it alone and every URL brust owns lies below it. */
104
104
  base_path?: string
105
+ /** F506 `[server] trailing_slash`: set only when it is not `strict` (`redirect` | `ignore`). */
106
+ trailing_slash?: 'redirect' | 'ignore'
105
107
  /** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
106
108
  actions_remote?: { prefix: string }
107
109
  /** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
@@ -177,6 +177,10 @@ export async function writeStatic(
177
177
  o.log(w)
178
178
  }
179
179
  for (const w of assets.warnings) o.log(w)
180
+ // F506: a static host serves the files as it likes; brust's trailing-slash policy applies to `brust start` only.
181
+ const ts = (JSON.parse(readFileSync(join(dist, 'manifest.json'), 'utf8')) as { trailing_slash?: string }).trailing_slash
182
+ if (ts !== undefined && ts !== 'strict')
183
+ warn(`warning trailing-slash-static server.trailing_slash = ${JSON.stringify(ts)} does not apply to --target static: the static host decides how a path ending in '/' is served`)
180
184
 
181
185
  // R9.4: [cache] does not apply to static files, so the render must not need its adapter (a Redis
182
186
  // that is not running at build time): the render reads a manifest without `cache`, and the dist
@@ -68,8 +68,7 @@ const addCommand = (v: string) => `bun add -d --exact ${TAILWIND_PACKAGES.map((p
68
68
  /** The Tailwind plugin of the CSS build, resolved from the app. Missing packages → `tailwind-missing` with the
69
69
  * exact install command; versions that differ → one warning through `log` with the command that aligns them.
70
70
  * `uses(file)` says whether a stylesheet needs the compiler (it, or a relative `@import` of it, uses Tailwind);
71
- * other stylesheets stay Bun's. `ownOutput`: app-root-relative, `/`-separated directories of brust's own output that no
72
- * scan may read (F501). */
71
+ * other stylesheets stay Bun's. `ownOutput`: absolute directories of brust's own output that no scan may read (F501, F505). */
73
72
  export async function loadTailwind(
74
73
  appRoot: string,
75
74
  usedBy: string,
@@ -112,9 +111,12 @@ export async function loadTailwind(
112
111
  const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
113
112
  const root: SourceEntry[] =
114
113
  compiler.root === 'none' ? [] : compiler.root === null ? [{ base: appRoot, pattern: '**/*', negated: false }] : [{ ...compiler.root, negated: false }]
115
- // F501: brust's own output (the dist dir, `.brust`) is never a source, whatever the ignore files say and whatever
114
+ // F501: brust's own output (the dist dirs, `.brust`) is never a source, whatever the ignore files say and whatever
116
115
  // the stylesheet's `source(...)` and `@source` lines add: last, so a stale build can never feed the next one.
117
- const own: SourceEntry[] = ownOutput.map((dir) => ({ base: appRoot, pattern: `${dir}/**`, negated: true }))
116
+ // F505: each directory is the BASE of its own negation (verified: oxide honours a negated entry whose base differs
117
+ // from the scanned root, a base outside the root, a base that does not exist yet, and a name with glob characters
118
+ // such as `dist[1]`, none of which a pattern relative to the app root would survive).
119
+ const own: SourceEntry[] = ownOutput.map((dir) => ({ base: dir, pattern: '**/*', negated: true }))
118
120
  const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources, ...own] })
119
121
  let out = compiler.build(scanner.scan())
120
122
  // `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
package/src/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // Runtime configuration (spec §8). Precedence, high → low:
2
2
  // env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
3
- // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
3
+ // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES / BRUST_STATS)
4
4
  // > CLI flags (`brust start --port/--workers`) and `run({ config })`
5
- // > brust.toml ([server] address / port / base_path / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
5
+ // > brust.toml ([server] address / port / base_path / stats / stats_token / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
6
6
  // > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
7
7
  // L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
8
8
  // `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
@@ -24,6 +24,20 @@ export interface CorsConfig {
24
24
  maxAgeSeconds?: number
25
25
  }
26
26
 
27
+ /** F507 `[server] stats`: who may read `/_brust/cache/stats`. */
28
+ export const STATS_MODES = ['off', 'public', 'token'] as const
29
+ export type StatsMode = (typeof STATS_MODES)[number]
30
+ /** F507 `[server] stats_token`: an env reference only, never the secret itself (like `[cache.redis] url`). */
31
+ const STATS_TOKEN_REF = /^env:[A-Za-z_][A-Za-z0-9_]*$/
32
+
33
+ /** Why `[server] stats` / `stats_token` (or `run({ config })`) are wrong, or `undefined`. The secret never appears in the message. */
34
+ function statsError(mode: unknown, token: unknown, where: string): string | undefined {
35
+ if (mode !== undefined && !STATS_MODES.includes(mode as StatsMode)) return `${where}stats must be one of ${STATS_MODES.join(', ')} (got ${JSON.stringify(mode)})`
36
+ if (token !== undefined && (typeof token !== 'string' || !STATS_TOKEN_REF.test(token)))
37
+ return `${where}stats_token must be "env:NAME" (a literal token is refused: it would sit in a file)`
38
+ return undefined
39
+ }
40
+
27
41
  export interface BrustConfig {
28
42
  host: string
29
43
  port: number
@@ -47,6 +61,15 @@ export interface BrustConfig {
47
61
  * root), absent when nothing set it. A build setting too (`brust build` bakes it into the manifest): `brust start` refuses a manifest
48
62
  * built for another one, and with none set serves the one the manifest names. */
49
63
  basePath?: string
64
+ /** F506 `[server] trailing_slash` / `run({ config: { trailingSlash } })`: what a page request whose path ends in `/` gets — `strict` (matched
65
+ * as written, the default), `redirect` (GET/HEAD answer 308 to the path without it, query kept) or `ignore` (the slash is dropped before
66
+ * matching). A build setting too (baked into the manifest): `brust start` refuses a manifest built with another one. */
67
+ trailingSlash?: TrailingSlash
68
+ /** F507 `[server] stats` / `run({ config: { stats } })`: who may read `/_brust/cache/stats` (`off` = 404, `public`, `token` = a bearer
69
+ * token). Absent = `off` for `brust start`, `public` under `brust dev`. */
70
+ stats?: StatsMode
71
+ /** F507 `[server] stats_token` / `run({ config: { statsToken } })`: `"env:NAME"`, the variable that holds the bearer token (`stats = "token"` only). */
72
+ statsToken?: string
50
73
  /** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
51
74
  actionsBaseUrl?: string
52
75
  /** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
@@ -56,6 +79,16 @@ export interface BrustConfig {
56
79
  /** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
57
80
  export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
58
81
 
82
+ /** F506: the `[server] trailing_slash` values. */
83
+ export const TRAILING_SLASH = ['strict', 'redirect', 'ignore'] as const
84
+ export type TrailingSlash = (typeof TRAILING_SLASH)[number]
85
+
86
+ /** F506: `[server] trailing_slash`, validated; the reason it is refused otherwise. */
87
+ export function checkTrailingSlash(raw: unknown): { ok: true; value: TrailingSlash } | { ok: false; why: string } {
88
+ if (typeof raw === 'string' && (TRAILING_SLASH as readonly string[]).includes(raw)) return { ok: true, value: raw as TrailingSlash }
89
+ return { ok: false, why: `must be one of ${TRAILING_SLASH.map((v) => JSON.stringify(v)).join(', ')} (got ${JSON.stringify(raw)})` }
90
+ }
91
+
59
92
  /** F495: `[server] base_path`, validated: `/seg[/seg…]`, no trailing `/`, each segment `[A-Za-z0-9._~-]+` (not `.`/`..`), the
60
93
  * same alphabet as the actions prefix, so it needs no percent-encoding anywhere it is printed. `""` and `"/"` mean the
61
94
  * root (`""`). Returns the value, or the reason it is refused. */
@@ -116,6 +149,11 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
116
149
  checkCli(cli)
117
150
  // cli (validated, normalised) over toml as a whole object (R10.2); `null` = off.
118
151
  const cors = cli.cors === undefined ? fromToml.cors : cli.cors === null ? undefined : checkCors(cli.cors, 'config.cors', null, false)
152
+ // F507: the two keys belong together, wherever each one came from.
153
+ const stats = env.stats ?? cli.stats ?? fromToml.stats
154
+ const statsToken = cli.statsToken ?? fromToml.statsToken
155
+ if (stats === 'token' && statsToken === undefined) throw new BrustConfigError('stats = "token" needs stats_token = "env:NAME" (the variable that holds the bearer token)', null)
156
+ if (statsToken !== undefined && stats !== 'token') throw new BrustConfigError('stats_token is used only with stats = "token"', null)
119
157
  return {
120
158
  host: env.host ?? cli.host ?? fromToml.host ?? DEFAULTS.host,
121
159
  port: env.port ?? cli.port ?? fromToml.port ?? DEFAULTS.port,
@@ -130,6 +168,9 @@ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrid
130
168
  ioThreads: env.ioThreads ?? cli.ioThreads,
131
169
  ...(cors ? { cors } : {}),
132
170
  ...pick('basePath', cli.basePath ?? fromToml.basePath),
171
+ ...pick('trailingSlash', cli.trailingSlash ?? fromToml.trailingSlash),
172
+ ...pick('stats', stats),
173
+ ...pick('statsToken', statsToken),
133
174
  ...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
134
175
  ...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
135
176
  }
@@ -173,6 +214,12 @@ function checkCli(cli: ConfigOverrides): void {
173
214
  if (!r.ok) throw new BrustConfigError(`config.basePath ${r.why}`, null)
174
215
  cli.basePath = r.value
175
216
  }
217
+ if (cli.trailingSlash !== undefined) {
218
+ const r = checkTrailingSlash(cli.trailingSlash)
219
+ if (!r.ok) throw new BrustConfigError(`config.trailingSlash ${r.why}`, null)
220
+ }
221
+ const badStats = statsError(cli.stats, cli.statsToken, 'config.')
222
+ if (badStats) throw new BrustConfigError(badStats, null)
176
223
  if (cli.actionsBaseUrl !== undefined) {
177
224
  const why = baseUrlError(cli.actionsBaseUrl)
178
225
  if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
@@ -204,6 +251,15 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
204
251
  if (!r.ok) throw new BrustConfigError(`${file}: server.base_path ${r.why}`, file)
205
252
  out.basePath = r.value
206
253
  }
254
+ if (server.trailing_slash !== undefined) {
255
+ const r = checkTrailingSlash(server.trailing_slash)
256
+ if (!r.ok) throw new BrustConfigError(`${file}: server.trailing_slash ${r.why}`, file)
257
+ out.trailingSlash = r.value
258
+ }
259
+ const bad = statsError(server.stats, server.stats_token, `${file}: server.`)
260
+ if (bad) throw new BrustConfigError(bad, file)
261
+ if (server.stats !== undefined) out.stats = server.stats as StatsMode
262
+ if (server.stats_token !== undefined) out.statsToken = server.stats_token as string
207
263
  const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
208
264
  if (cap !== undefined) out.maxLoaderResponseBytes = cap
209
265
  if (port !== undefined) {
@@ -320,6 +376,11 @@ function fromEnv(): Partial<BrustConfig> {
320
376
  out.slotBytes = envInt('BRUST_SLOT_BYTES', MIN_SLOT_BYTES, MAX_SLOT_BYTES, `an integer in ${MIN_SLOT_BYTES}..${MAX_SLOT_BYTES}`)
321
377
  out.maxLoaderResponseBytes = envInt('BRUST_MAX_LOADER_RESPONSE_BYTES', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, `an integer in ${MIN_LOADER_RESPONSE_BYTES}..${MAX_LOADER_RESPONSE_BYTES}`)
322
378
  out.ioThreads = envInt('BRUST_WORKER_THREADS', 1, MAX_IO_THREADS, `an integer in 1..${MAX_IO_THREADS}`)
379
+ const stats = process.env.BRUST_STATS
380
+ if (stats !== undefined && stats !== '') {
381
+ if (!STATS_MODES.includes(stats as StatsMode)) throw new BrustConfigError(`BRUST_STATS must be one of ${STATS_MODES.join(', ')} (got ${JSON.stringify(stats)})`, null)
382
+ out.stats = stats as StatsMode
383
+ }
323
384
  const base = process.env.BRUST_ACTIONS_BASE_URL
324
385
  if (base !== undefined && base !== '') {
325
386
  const why = baseUrlError(base)
@@ -15,12 +15,63 @@ export function pidAlive(pid: number): boolean {
15
15
  }
16
16
  }
17
17
 
18
+ /** F492 (PID reuse): the identity of a process that outlives a PID: its creation time (a FILETIME, 100 ns ticks since 1601), read once
19
+ * through kernel32 `GetProcessTimes` (`OpenProcess` with the query-limited right: no elevation, no process table, no PowerShell). A PID
20
+ * that is reused by another process has another creation time. `undefined` when it cannot be read (not Windows, the process is gone, no
21
+ * access, `bun:ffi` missing): the watch then keeps its pid-only behaviour. Synchronous, a few microseconds, nothing awaited. */
22
+ export function processIdentity(pid: number): string | undefined {
23
+ if (process.platform !== 'win32') return undefined
24
+ const k = kernel32()
25
+ if (!k) return undefined
26
+ try {
27
+ const h = k.OpenProcess(0x1000 /* PROCESS_QUERY_LIMITED_INFORMATION */, 0, pid)
28
+ if (!h) return undefined
29
+ try {
30
+ const t = new BigUint64Array(4) // creation, exit, kernel, user
31
+ const base = k.ptr(t)
32
+ if (!k.GetProcessTimes(h, base, k.ptr(t, 8), k.ptr(t, 16), k.ptr(t, 24))) return undefined
33
+ return t[0]!.toString()
34
+ } finally {
35
+ k.CloseHandle(h)
36
+ }
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ type Kernel32 = {
43
+ OpenProcess(access: number, inherit: number, pid: number): unknown
44
+ GetProcessTimes(h: unknown, a: unknown, b: unknown, c: unknown, d: unknown): number
45
+ CloseHandle(h: unknown): number
46
+ ptr: (view: ArrayBufferView, offset?: number) => never
47
+ }
48
+ let k32: Kernel32 | null | undefined
49
+ /** The three kernel32 bindings, opened ONCE for the life of the process: `dlopen` compiles a binding per symbol and holds it until
50
+ * the library is closed, and the watch reads the identity every tick of a long-running server. `null` = unavailable (kept). */
51
+ function kernel32(): Kernel32 | null {
52
+ if (k32 !== undefined) return k32
53
+ try {
54
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
55
+ const s = dlopen('kernel32.dll', {
56
+ OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
57
+ GetProcessTimes: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
58
+ CloseHandle: { args: [FFIType.ptr], returns: FFIType.i32 },
59
+ }).symbols
60
+ k32 = { ...(s as unknown as Omit<Kernel32, 'ptr'>), ptr: ptr as Kernel32['ptr'] }
61
+ } catch {
62
+ k32 = null
63
+ }
64
+ return k32
65
+ }
66
+
18
67
  export interface LauncherWatchOptions {
19
68
  /** Runs once, when the parent is gone. */
20
69
  onGone: (pid: number) => void
21
70
  platform?: NodeJS.Platform
22
71
  ppid?: number
23
72
  alive?: (pid: number) => boolean
73
+ /** The parent's identity (default: its creation time, win32). Read once at boot and again on each tick while the pid answers alive: a different value means the pid was reused, so the launcher is gone. */
74
+ identity?: (pid: number) => string | undefined
24
75
  intervalMs?: number
25
76
  log?: (line: string) => void
26
77
  }
@@ -35,8 +86,10 @@ export function watchLauncher(opts: LauncherWatchOptions): { pid: number | undef
35
86
  ;(opts.log ?? console.error)(`[brust] launcher (pid ${ppid}) is already gone at boot: not watching it`)
36
87
  return none
37
88
  }
89
+ const identity = opts.identity ?? processIdentity
90
+ const born = identity(ppid)
38
91
  const timer = setInterval(() => {
39
- if (alive(ppid)) return
92
+ if (alive(ppid) && (born === undefined || identity(ppid) === born)) return
40
93
  clearInterval(timer)
41
94
  opts.onGone(ppid)
42
95
  }, opts.intervalMs ?? 1000)
package/src/native.ts CHANGED
@@ -46,6 +46,10 @@ export interface StartOptions {
46
46
  l2?: L2Options
47
47
  /** `brust dev` only (L14): a request carrying this token (`x-brust-dev-token`) is traced; absent in production. */
48
48
  devToken?: string
49
+ /** F507: who may read `/_brust/cache/stats`; absent = `off` (404). Boot error `stats: …` for a mode and token that do not go together. */
50
+ stats?: 'off' | 'public' | 'token'
51
+ /** F507: the secret `stats: 'token'` compares `Authorization: Bearer` with, resolved from its env reference; never logged. */
52
+ statsToken?: string
49
53
  }
50
54
  /** napi `NapiL2Options`; defaults 50 ms, 1048576 bytes. */
51
55
  export interface L2Options {
package/src/run.ts CHANGED
@@ -37,6 +37,8 @@ export interface RunManifest {
37
37
  cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string } | null
38
38
  /** F495: the `[server] base_path` the build baked in; absent = the app is at the root. */
39
39
  base_path?: string
40
+ /** F506: the `[server] trailing_slash` the build baked in; absent = `strict`. */
41
+ trailing_slash?: string
40
42
  /** F165: `false` = no route can call a worker; absent (older manifests) = the pool is needed. */
41
43
  needs_workers?: boolean
42
44
  }
@@ -57,7 +59,9 @@ export function workerCount(cfg: BrustConfig, m: RunManifest): number {
57
59
  }
58
60
 
59
61
  /** Pure: the napi `StartOptions`. No actions/cors/cache → exactly the pre-actions keys. */
60
- export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string): StartOptions {
62
+ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest, version: string, devToken?: string, env: Record<string, string | undefined> = process.env): StartOptions {
63
+ // F507: `/_brust/cache/stats` is off unless set; `brust dev` (its server carries a dev token) defaults to public.
64
+ const stats = cfg.stats ?? (devToken ? 'public' : 'off')
61
65
  return {
62
66
  host: cfg.host,
63
67
  port: cfg.port,
@@ -71,10 +75,19 @@ export function startOptions(cfg: BrustConfig, distDir: string, m: RunManifest,
71
75
  ...(actionsClient(cfg, m) ? { actionsClient: actionsClient(cfg, m) } : {}),
72
76
  ...(cfg.cors ? { cors: cfg.cors } : {}),
73
77
  ...(devToken ? { devToken } : {}),
78
+ ...(stats !== 'off' ? { stats, ...(stats === 'token' ? { statsToken: statsTokenOf(cfg.statsToken, env) } : {}) } : {}),
74
79
  ...(m.cache ? { l2: { adapter: m.cache.adapter, getTimeoutMs: m.cache.getTimeoutMs, maxBodyBytes: m.cache.maxBodyBytes, namespace: m.cache.namespace, buildId: m.cache.buildId } } : {}),
75
80
  }
76
81
  }
77
82
 
83
+ /** F507: the bearer token `[server] stats_token = "env:NAME"` names; a boot error naming the variable (never a value) when it is unset or empty. */
84
+ function statsTokenOf(ref: string | undefined, env: Record<string, string | undefined>): string {
85
+ const name = (ref ?? '').replace(/^env:/, '')
86
+ const v = name ? env[name] : undefined
87
+ if (!v) throw new Error(`stats: [server] stats_token is "env:${name}" but ${name} is not set (or empty)`)
88
+ return v
89
+ }
90
+
78
91
  /** L16: what the page tells the browser `client()` (R16.1/R16.2); absent while it would only repeat the prefix script. */
79
92
  function actionsClient(cfg: BrustConfig, m: RunManifest): NonNullable<StartOptions['actionsClient']> | undefined {
80
93
  const credentials = cfg.actionsCredentials !== undefined && cfg.actionsCredentials !== 'same-origin' ? cfg.actionsCredentials : undefined
@@ -92,6 +105,14 @@ export function basePathMismatch(cfg: Pick<BrustConfig, 'basePath'>, m: Pick<Run
92
105
  return `base-path-mismatch: server.base_path is ${show(cfg.basePath)} but this build is for ${show(built)}; the base path is baked in at build time, so rebuild (brust build)`
93
106
  }
94
107
 
108
+ /** F506: the message when the config names a trailing-slash policy other than the one the manifest was built with
109
+ * (`trailing-slash-mismatch`), else null. A config that sets none serves the manifest's. */
110
+ export function trailingSlashMismatch(cfg: Pick<BrustConfig, 'trailingSlash'>, m: Pick<RunManifest, 'trailing_slash'>): string | null {
111
+ const built = m.trailing_slash ?? 'strict'
112
+ if (cfg.trailingSlash === undefined || cfg.trailingSlash === built) return null
113
+ return `trailing-slash-mismatch: server.trailing_slash is ${JSON.stringify(cfg.trailingSlash)} but this build is for ${JSON.stringify(built)}; it is baked in at build time, so rebuild (brust build)`
114
+ }
115
+
95
116
  /** Every worker's response buffer, kept reachable until the process exits (see run()). */
96
117
  const workerBuffers: SharedArrayBuffer[] = []
97
118
 
@@ -122,7 +143,7 @@ export async function run(opts: RunOptions = {}): Promise<void> {
122
143
  fail((e as Error).message)
123
144
  }
124
145
 
125
- const mismatch = basePathMismatch(cfg, m)
146
+ const mismatch = basePathMismatch(cfg, m) ?? trailingSlashMismatch(cfg, m)
126
147
  if (mismatch) fail(mismatch)
127
148
  // `basePath` of `@brust/core` in the app's modules (the workers inherit the env).
128
149
  process.env.BRUST_BASE_PATH = m.base_path ?? ''
@@ -210,6 +231,8 @@ export async function run(opts: RunOptions = {}): Promise<void> {
210
231
  // F485 P12: on Windows Ctrl+C (SIGINT) drains; SIGTERM handlers rarely fire and a kill ends the process without a drain (POSIX-only).
211
232
  process.on('SIGINT', () => onSignal(130))
212
233
  process.on('SIGTERM', () => onSignal(143))
234
+ // F492: a console Ctrl+Break (and closing the console) reaches Bun as SIGBREAK on Windows: drain like Ctrl+C.
235
+ if (process.platform === 'win32') process.on('SIGBREAK', () => onSignal(149))
213
236
 
214
237
  // F488 (Windows only): a killed `bun run start` wrapper takes the shim (our parent) down but leaves this process running; drain when the
215
238
  // parent is gone (same path as Ctrl+C). Synchronous, nothing awaited. Not for a one-shot (`opts.onReady`: a static render that exits itself).