@nifrajs/core 5.0.0 → 5.2.0
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/CHANGELOG.md +2384 -0
- package/dist/assurance.d.ts +15 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +97 -71
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/hook-origins.d.ts +17 -1
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +20 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +2 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +22 -17
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reflection.d.ts +23 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +57 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.d.ts +7 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +37 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/headers.d.ts +9 -0
- package/dist/server/headers.d.ts.map +1 -1
- package/dist/server/headers.js +26 -0
- package/dist/server/headers.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +57 -7
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +394 -23
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -9
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +40 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +188 -43
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +11 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +26 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +9 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +81 -10
- package/dist/single-copy.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +2 -1
- package/dist/ws.js.map +1 -1
- package/package.json +3 -2
- package/src/assurance.ts +120 -81
- package/src/capabilities.ts +3 -1
- package/src/edge-kit.ts +1 -1
- package/src/index.ts +2 -2
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +39 -20
- package/src/internal/same-origin.ts +14 -1
- package/src/reflection.ts +61 -1
- package/src/server/body-lane.ts +36 -1
- package/src/server/client-ip.ts +5 -5
- package/src/server/context.ts +7 -4
- package/src/server/cookies.ts +12 -1
- package/src/server/core-copies.ts +40 -0
- package/src/server/edge.ts +2 -1
- package/src/server/headers.ts +39 -0
- package/src/server/http.ts +7 -1
- package/src/server/response-contract-lane.ts +491 -24
- package/src/server/server-types.ts +23 -9
- package/src/server/server.ts +249 -35
- package/src/server/websocket.ts +39 -13
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +9 -2
- package/src/server.ts +1 -1
- package/src/single-copy.ts +90 -9
- package/src/wire.ts +14 -0
- package/src/ws.ts +2 -0
package/src/server/websocket.ts
CHANGED
|
@@ -233,8 +233,19 @@ export type WebSocketUpgradeOutcome =
|
|
|
233
233
|
* `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
|
|
234
234
|
readonly attach: WsAttach
|
|
235
235
|
readonly maxPayloadBytes?: number
|
|
236
|
+
/** Builds `ws.send` over an adapter's native send, enforcing the route's {@link
|
|
237
|
+
* WebSocketHandler.validateSend}. Set on every outcome a nifra app produces; an adapter that
|
|
238
|
+
* wires its own sockets instead of calling `attach` (Node, Deno) builds `send` with it. */
|
|
239
|
+
readonly sender?: WsSender
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
/** The send-builder the upgrade outcome carries (the installed runtime's `sender`). */
|
|
243
|
+
export type WsSender = (
|
|
244
|
+
handler: WebSocketHandler,
|
|
245
|
+
send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
|
|
246
|
+
onError: (error: unknown) => void,
|
|
247
|
+
) => (payload: string | ArrayBufferView | ArrayBuffer) => void
|
|
248
|
+
|
|
238
249
|
/** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */
|
|
239
250
|
export type WsAttach = (
|
|
240
251
|
socket: StandardWebSocket,
|
|
@@ -325,18 +336,11 @@ export function attachWebSocket<
|
|
|
325
336
|
socket.addEventListener("message", (event: { readonly data: unknown }) => {
|
|
326
337
|
const raw = event.data
|
|
327
338
|
const payload: WebSocketData = typeof raw === "string" ? raw : toBinary(raw)
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
typeof raw === "string"
|
|
334
|
-
? WS_PAYLOAD_ENCODER.encode(raw).byteLength
|
|
335
|
-
: (payload as Uint8Array).byteLength
|
|
336
|
-
if (payloadBytes > maxPayloadBytes) {
|
|
337
|
-
socket.close(1009, "message too large")
|
|
338
|
-
return
|
|
339
|
-
}
|
|
339
|
+
// The runtime hands over each message whole, so this bounds what reaches the handler, not what
|
|
340
|
+
// the runtime buffered to deliver it.
|
|
341
|
+
if (maxPayloadBytes !== undefined && exceedsPayloadCap(payload, maxPayloadBytes)) {
|
|
342
|
+
socket.close(1009, "message too large")
|
|
343
|
+
return
|
|
340
344
|
}
|
|
341
345
|
safe(() =>
|
|
342
346
|
(
|
|
@@ -363,7 +367,25 @@ export function attachWebSocket<
|
|
|
363
367
|
}
|
|
364
368
|
|
|
365
369
|
const WS_MESSAGE_DECODER = new TextDecoder()
|
|
366
|
-
|
|
370
|
+
|
|
371
|
+
/** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
|
|
372
|
+
* 1-3 bytes (a surrogate pair 4 for its 2), so only a length between those bounds is counted. */
|
|
373
|
+
export function exceedsPayloadCap(
|
|
374
|
+
message: string | ArrayBuffer | Uint8Array,
|
|
375
|
+
cap: number,
|
|
376
|
+
): boolean {
|
|
377
|
+
if (typeof message !== "string") return message.byteLength > cap
|
|
378
|
+
const units = message.length
|
|
379
|
+
if (units > cap) return true
|
|
380
|
+
if (units * 3 <= cap) return false
|
|
381
|
+
let bytes = 0
|
|
382
|
+
for (let i = 0; i < units; i++) {
|
|
383
|
+
const unit = message.charCodeAt(i)
|
|
384
|
+
bytes += unit < 0x80 ? 1 : unit < 0x800 || (unit >= 0xd800 && unit <= 0xdfff) ? 2 : 3
|
|
385
|
+
if (bytes > cap) return true
|
|
386
|
+
}
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
367
389
|
|
|
368
390
|
type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer
|
|
369
391
|
|
|
@@ -448,6 +470,10 @@ export function createWebSocketSender(
|
|
|
448
470
|
}
|
|
449
471
|
}
|
|
450
472
|
|
|
473
|
+
/** {@link createWebSocketSender} configured from a route's handler: the outcome's `sender`. */
|
|
474
|
+
export const webSocketSender: WsSender = (handler, send, onError) =>
|
|
475
|
+
createWebSocketSender(send, handler.sendSchema, handler.validateSend, handler.transport, onError)
|
|
476
|
+
|
|
451
477
|
/**
|
|
452
478
|
* If the handler declares a `messageSchema`, return a copy whose `message` validates each frame -
|
|
453
479
|
* parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or
|
package/src/server/ws-bun.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
createWebSocketSender,
|
|
10
|
+
exceedsPayloadCap,
|
|
10
11
|
type NifraWebSocket,
|
|
11
12
|
type TopicRegistry,
|
|
12
13
|
type WebSocketData,
|
|
@@ -20,6 +21,9 @@ export interface BunWsData {
|
|
|
20
21
|
readonly handler: WebSocketHandler
|
|
21
22
|
/** Mounted/modern upgrades carry this registry; old/manual fixtures may omit it. */
|
|
22
23
|
readonly pubsub?: TopicRegistry
|
|
24
|
+
/** The inbound cap of the app this socket belongs to (the upgrade outcome's). Absent, the
|
|
25
|
+
* listening app's applies. */
|
|
26
|
+
readonly maxPayloadBytes?: number | undefined
|
|
23
27
|
data: unknown
|
|
24
28
|
nifra?: NifraWebSocket
|
|
25
29
|
}
|
|
@@ -38,6 +42,31 @@ export interface BunSocket {
|
|
|
38
42
|
readonly data: BunWsData
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The largest `wsMaxPayloadBytes` among the nifra apps mounted under `app` that serve sockets, or
|
|
47
|
+
* `floor` when none declares more. Bun takes one frame limit per listener, so it has to admit every
|
|
48
|
+
* mounted app's cap. Reads each server's own mount table, as reflection does, so an app that never
|
|
49
|
+
* listens pays nothing for it; a child that is not a nifra server names no cap here.
|
|
50
|
+
*/
|
|
51
|
+
function mountedPayloadCeiling(app: object, floor: number, seen: Set<object>): number {
|
|
52
|
+
if (seen.has(app)) return floor
|
|
53
|
+
seen.add(app)
|
|
54
|
+
const mounts: unknown = Reflect.get(app, "fetchMounts")
|
|
55
|
+
if (!Array.isArray(mounts)) return floor
|
|
56
|
+
let ceiling = floor
|
|
57
|
+
for (const mount of mounts) {
|
|
58
|
+
const child: unknown = typeof mount === "object" && mount !== null ? mount.app : undefined
|
|
59
|
+
if (typeof child !== "object" || child === null) continue
|
|
60
|
+
const routes: unknown = Reflect.get(child, "wsRoutes")
|
|
61
|
+
const cap: unknown = Reflect.get(child, "wsMaxPayloadBytes")
|
|
62
|
+
if (Array.isArray(routes) && routes.length > 0 && typeof cap === "number" && cap > ceiling) {
|
|
63
|
+
ceiling = cap
|
|
64
|
+
}
|
|
65
|
+
ceiling = mountedPayloadCeiling(child, ceiling, seen)
|
|
66
|
+
}
|
|
67
|
+
return ceiling
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
/** Normalize a received binary frame to `Uint8Array` (Bun hands a `Buffer`, already one; an
|
|
42
71
|
* `ArrayBuffer` is wrapped). Text frames stay `string`. */
|
|
43
72
|
function toBinaryData(message: ArrayBuffer | Uint8Array): Uint8Array {
|
|
@@ -127,9 +156,21 @@ function dispatchWsCallback(
|
|
|
127
156
|
* `nativePubsub` (set by `listen()` when the app has no validated-send route) makes subscriptions use
|
|
128
157
|
* Bun's own pub/sub; there the registry holds nothing and Bun auto-drops native subscriptions on
|
|
129
158
|
* close, so the `unsubscribeAll` sweep is skipped.
|
|
159
|
+
*
|
|
160
|
+
* `app` is the listening server. Its `wsMaxPayloadBytes` and its mounted apps' set the listener's
|
|
161
|
+
* `maxPayloadLength`; a socket whose app declares less is closed with `1009` here.
|
|
130
162
|
*/
|
|
131
|
-
export function createBunWsHandlers(
|
|
163
|
+
export function createBunWsHandlers(
|
|
164
|
+
topics: TopicRegistry,
|
|
165
|
+
nativePubsub = false,
|
|
166
|
+
app?: object,
|
|
167
|
+
): BunWsHandlers {
|
|
168
|
+
const own: unknown = app === undefined ? undefined : Reflect.get(app, "wsMaxPayloadBytes")
|
|
169
|
+
const appCap = typeof own === "number" ? own : Number.POSITIVE_INFINITY
|
|
170
|
+
const listenerCap =
|
|
171
|
+
app === undefined ? appCap : mountedPayloadCeiling(app, appCap, new Set<object>())
|
|
132
172
|
return {
|
|
173
|
+
maxPayloadLength: listenerCap,
|
|
133
174
|
open: (raw) => {
|
|
134
175
|
const ws = raw as BunSocket
|
|
135
176
|
const nifra = wrapBunSocket(ws, topics, ws.data.handler, nativePubsub)
|
|
@@ -138,6 +179,12 @@ export function createBunWsHandlers(topics: TopicRegistry, nativePubsub = false)
|
|
|
138
179
|
},
|
|
139
180
|
message: (raw, message) => {
|
|
140
181
|
const ws = raw as BunSocket
|
|
182
|
+
// Only a socket whose app declares less than the listener admits pays for a size check.
|
|
183
|
+
const cap = ws.data.maxPayloadBytes ?? appCap
|
|
184
|
+
if (cap < listenerCap && exceedsPayloadCap(message, cap)) {
|
|
185
|
+
ws.close(1009, "message too large")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
141
188
|
const handler = ws.data.handler
|
|
142
189
|
const nifra = ws.data.nifra ?? wrapBunSocket(ws, topics, handler, nativePubsub)
|
|
143
190
|
const data: WebSocketData = typeof message === "string" ? message : toBinaryData(message)
|
package/src/server/ws-hook.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
TopicRegistry,
|
|
16
16
|
WebSocketContext,
|
|
17
17
|
WebSocketHandler,
|
|
18
|
+
WsSender,
|
|
18
19
|
} from "./websocket.ts"
|
|
19
20
|
|
|
20
21
|
/** The Bun serve `websocket` callback trio, closed over one app's `TopicRegistry`. The `ws`
|
|
@@ -24,6 +25,9 @@ export interface BunWsHandlers {
|
|
|
24
25
|
open(ws: unknown): void
|
|
25
26
|
message(ws: unknown, message: string | ArrayBuffer | Uint8Array): void
|
|
26
27
|
close(ws: unknown, code: number, reason: string): void
|
|
28
|
+
/** The listener's `maxPayloadLength`: the largest `wsMaxPayloadBytes` among the app and the apps
|
|
29
|
+
* mounted on it. A socket whose app declares less checks its own cap per message. */
|
|
30
|
+
readonly maxPayloadLength: number
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** What `@nifrajs/core/ws` registers: everything `server.ts` and `toFetchHandler` need at runtime. */
|
|
@@ -37,8 +41,9 @@ export interface WsRuntime {
|
|
|
37
41
|
/** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
|
|
38
42
|
createTopics(): TopicRegistry
|
|
39
43
|
/** The Bun `websocket` config for `listen()` when the app has WS routes. `nativePubsub` routes
|
|
40
|
-
* `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames).
|
|
41
|
-
|
|
44
|
+
* `ws.subscribe` through Bun's own pub/sub (set when no route validates outbound frames). `app` is
|
|
45
|
+
* the listening server, whose mount table names the payload caps the listener has to admit. */
|
|
46
|
+
bunHandlers(topics: TopicRegistry, nativePubsub?: boolean, app?: object): BunWsHandlers
|
|
42
47
|
/** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
|
|
43
48
|
attach(
|
|
44
49
|
socket: StandardWebSocket,
|
|
@@ -46,4 +51,6 @@ export interface WsRuntime {
|
|
|
46
51
|
data: unknown,
|
|
47
52
|
options: { openNow: boolean; pubsub: TopicRegistry },
|
|
48
53
|
): NifraWebSocket
|
|
54
|
+
/** `webSocketSender` - `ws.send` for an adapter that wires its own sockets (Node, Deno). */
|
|
55
|
+
sender: WsSender
|
|
49
56
|
}
|
package/src/server.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
12
12
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
13
|
-
export { isSameOriginRequest } from "./internal/same-origin.ts"
|
|
13
|
+
export { isSameOriginForm, isSameOriginRequest } from "./internal/same-origin.ts"
|
|
14
14
|
export {
|
|
15
15
|
ROUTE_PATTERN_OVERLAP_MAX_STATES,
|
|
16
16
|
RoutePatternOverlapLimitError,
|
package/src/single-copy.ts
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
57
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
58
|
-
import { dirname, join, relative, resolve, sep } from "node:path"
|
|
58
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"
|
|
59
59
|
import type { BunPlugin } from "bun"
|
|
60
60
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
61
61
|
|
|
@@ -339,6 +339,26 @@ const installedCopy = (
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** The package a bare specifier names, without its subpath: `@scope/name` or `name`. */
|
|
343
|
+
const packageNameOf = (specifier: string): string =>
|
|
344
|
+
specifier
|
|
345
|
+
.split("/")
|
|
346
|
+
.slice(0, specifier.startsWith("@") ? 2 : 1)
|
|
347
|
+
.join("/")
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Whether Bun's own node_modules walk from `importer` already reaches the app's copy. Bun then keeps
|
|
351
|
+
* the package's `sideEffects: false` pruning; a path handed back from `onResolve` loses it, so the
|
|
352
|
+
* bundle reads every module the entry re-exports, `node:` imports included.
|
|
353
|
+
*/
|
|
354
|
+
const reachesAppCopy = (specifier: string, importer: string, root: string): boolean => {
|
|
355
|
+
if (!isAbsolute(importer)) return false
|
|
356
|
+
const name = packageNameOf(specifier)
|
|
357
|
+
const ours = installedCopy(root, name, parse(root).root)
|
|
358
|
+
if (ours === undefined) return false
|
|
359
|
+
return installedCopy(dirname(importer), name, parse(importer).root)?.root === ours.root
|
|
360
|
+
}
|
|
361
|
+
|
|
342
362
|
/** Every `node_modules` from the app root up to (and including) its repository root. */
|
|
343
363
|
const nodeModulesDirs = (root: string): readonly string[] => {
|
|
344
364
|
const boundary = repoBoundary(root)
|
|
@@ -355,6 +375,17 @@ const nodeModulesDirs = (root: string): readonly string[] => {
|
|
|
355
375
|
return dirs
|
|
356
376
|
}
|
|
357
377
|
|
|
378
|
+
/** Where a real package folder lives when only its files are links: Bun installs a `file:` folder
|
|
379
|
+
* dependency as a directory of per-file symlinks, so its imports resolve beside the linked files. */
|
|
380
|
+
const fileLinkedDir = (dir: string): string | undefined => {
|
|
381
|
+
const manifest = join(dir, "package.json")
|
|
382
|
+
try {
|
|
383
|
+
return lstatSync(manifest).isSymbolicLink() ? dirname(realpathOrSelf(manifest)) : undefined
|
|
384
|
+
} catch {
|
|
385
|
+
return undefined
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
358
389
|
/**
|
|
359
390
|
* Package directories that are symlinked OUT of this repository - the `link:` / `file:` / `npm link`
|
|
360
391
|
* consumers whose own imports resolve somewhere this install does not own. Those are the only
|
|
@@ -388,9 +419,8 @@ const linkedPackageDirs = (root: string): readonly string[] => {
|
|
|
388
419
|
} catch {
|
|
389
420
|
continue
|
|
390
421
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (inside(repo, target)) continue
|
|
422
|
+
const target = link.isSymbolicLink() ? realpathOrSelf(candidate) : fileLinkedDir(candidate)
|
|
423
|
+
if (target === undefined || inside(repo, target)) continue
|
|
394
424
|
found.add(target)
|
|
395
425
|
}
|
|
396
426
|
}
|
|
@@ -550,6 +580,7 @@ export function singleCopyPlugin(options: SingleCopyOptions = {}): SingleCopyPlu
|
|
|
550
580
|
if (alternatives.length > 0) {
|
|
551
581
|
const filter = new RegExp(`^(?:${alternatives.join("|")})$`)
|
|
552
582
|
build.onResolve({ filter }, (args) => {
|
|
583
|
+
if (reachesAppCopy(args.path, args.importer, plan.root)) return undefined
|
|
553
584
|
try {
|
|
554
585
|
return { path: Bun.resolveSync(args.path, plan.root) }
|
|
555
586
|
} catch {
|
|
@@ -635,6 +666,36 @@ const describeSkips = (name: string, skips: readonly SingleCopySkip[]): string =
|
|
|
635
666
|
return lines.join("\n")
|
|
636
667
|
}
|
|
637
668
|
|
|
669
|
+
/** The `node_modules` folder a copy was installed into: `/kit/node_modules/@scope/pkg` -> `/kit/node_modules`. */
|
|
670
|
+
const modulesDirOf = (copy: string): string => {
|
|
671
|
+
const marker = `${sep}node_modules${sep}`
|
|
672
|
+
const at = copy.lastIndexOf(marker)
|
|
673
|
+
return at === -1 ? dirname(copy) : copy.slice(0, at + marker.length - 1)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Version skews that share both trees and both versions, as one report: a linked repository on
|
|
678
|
+
* another release skews every declared package at once, and a block per package said the same thing
|
|
679
|
+
* eighteen times. One package keeps the per-package report.
|
|
680
|
+
*/
|
|
681
|
+
const describeSkewGroup = (skips: readonly SingleCopySkip[]): string => {
|
|
682
|
+
const [first] = skips
|
|
683
|
+
if (first === undefined || skips.length === 1) return describeSkips(first?.package ?? "", skips)
|
|
684
|
+
const names = skips.map((skip) => skip.package).sort(codeUnitOrder)
|
|
685
|
+
return [
|
|
686
|
+
`[nifra] single-copy: ${names.length} packages are NOT deduplicated (version-skew) - a second copy of each loads, so their module state is not shared.`,
|
|
687
|
+
copyLine(
|
|
688
|
+
"app copies",
|
|
689
|
+
first.to === undefined ? undefined : modulesDirOf(first.to),
|
|
690
|
+
first.toVersion,
|
|
691
|
+
),
|
|
692
|
+
copyLine("linked copies", modulesDirOf(first.from), first.fromVersion),
|
|
693
|
+
` packages: ${names.join(", ")}`,
|
|
694
|
+
` ${first.fromVersion ?? "another version"} there, ${first.toVersion ?? "another version"} here - redirecting would serve a version those copies did not ask for`,
|
|
695
|
+
" Fix: align the dependency ranges so both trees install one version, then reinstall. nifra never redirects across versions.",
|
|
696
|
+
].join("\n")
|
|
697
|
+
}
|
|
698
|
+
|
|
638
699
|
const strictFromEnv = (): boolean => {
|
|
639
700
|
const value = typeof process === "undefined" ? undefined : process.env[SINGLE_COPY_STRICT_ENV]
|
|
640
701
|
return value === "1" || value === "true"
|
|
@@ -654,15 +715,15 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
|
|
|
654
715
|
const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
|
|
655
716
|
const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
|
|
656
717
|
const warned = processSet(WARNED)
|
|
657
|
-
const
|
|
658
|
-
const message = describeSkips(name, skips)
|
|
718
|
+
const emit = (key: string, message: string): void => {
|
|
659
719
|
if (strict)
|
|
660
720
|
throw new Error(`${message}\n Strict mode is on, so this process refuses to start.`)
|
|
661
|
-
const key = `${skips[0]?.reason ?? ""}\0${name}`
|
|
662
721
|
if (warned.has(key)) return
|
|
663
722
|
warned.add(key)
|
|
664
723
|
console.warn(message)
|
|
665
724
|
}
|
|
725
|
+
const report = (name: string, skips: readonly SingleCopySkip[]): void =>
|
|
726
|
+
emit(`${skips[0]?.reason ?? ""}\0${name}`, describeSkips(name, skips))
|
|
666
727
|
const plugin = singleCopyPlugin({
|
|
667
728
|
...options,
|
|
668
729
|
cwd: root,
|
|
@@ -672,13 +733,33 @@ export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyP
|
|
|
672
733
|
},
|
|
673
734
|
})
|
|
674
735
|
const byPackage = new Map<string, SingleCopySkip[]>()
|
|
736
|
+
const bySkew = new Map<string, SingleCopySkip[]>()
|
|
675
737
|
for (const skip of plugin.plan.skipped) {
|
|
676
738
|
options.onSkip?.(skip)
|
|
677
|
-
const
|
|
739
|
+
const skew =
|
|
740
|
+
skip.reason === "version-skew"
|
|
741
|
+
? [
|
|
742
|
+
modulesDirOf(skip.from),
|
|
743
|
+
skip.to === undefined ? "" : modulesDirOf(skip.to),
|
|
744
|
+
skip.fromVersion ?? "",
|
|
745
|
+
skip.toVersion ?? "",
|
|
746
|
+
].join("\0")
|
|
747
|
+
: undefined
|
|
748
|
+
const groups = skew === undefined ? byPackage : bySkew
|
|
749
|
+
const key = skew ?? skip.package
|
|
750
|
+
const group = groups.get(key) ?? []
|
|
678
751
|
group.push(skip)
|
|
679
|
-
|
|
752
|
+
groups.set(key, group)
|
|
680
753
|
}
|
|
681
754
|
for (const [name, skips] of byPackage) report(name, skips)
|
|
755
|
+
for (const skips of bySkew.values()) {
|
|
756
|
+
if (skips.length === 1) report(skips[0]?.package ?? "", skips)
|
|
757
|
+
else
|
|
758
|
+
emit(
|
|
759
|
+
`version-skew\0${skips.map((skip) => skip.package).join("\0")}`,
|
|
760
|
+
describeSkewGroup(skips),
|
|
761
|
+
)
|
|
762
|
+
}
|
|
682
763
|
// Installing twice (a preload listed twice, or an explicit call on top of the preload) would register
|
|
683
764
|
// two `onLoad` hooks for the same foreign files, and two hooks both answering one file stall the
|
|
684
765
|
// runtime loader. One install per root and declaration is the whole effect anyway.
|
package/src/wire.ts
CHANGED
|
@@ -43,6 +43,11 @@ export interface WireDecodeLimits {
|
|
|
43
43
|
readonly maxCollectionEntries?: number
|
|
44
44
|
/** Maximum UTF-8 string plus decoded binary bytes materialized. Default 16 MiB. */
|
|
45
45
|
readonly maxDecodedBytes?: number
|
|
46
|
+
/**
|
|
47
|
+
* Maximum decimal digits in one `bigint`. Default 4,096 (past 8192-bit integers). Parsing grows
|
|
48
|
+
* faster than the digit count, so one long token would cost seconds of CPU under the byte limit.
|
|
49
|
+
*/
|
|
50
|
+
readonly maxBigIntDigits?: number
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export const DEFAULT_WIRE_DECODE_LIMITS: Readonly<Required<WireDecodeLimits>> = Object.freeze({
|
|
@@ -50,6 +55,7 @@ export const DEFAULT_WIRE_DECODE_LIMITS: Readonly<Required<WireDecodeLimits>> =
|
|
|
50
55
|
maxDepth: 256,
|
|
51
56
|
maxCollectionEntries: 100_000,
|
|
52
57
|
maxDecodedBytes: 16 * 1024 * 1024,
|
|
58
|
+
maxBigIntDigits: 4096,
|
|
53
59
|
})
|
|
54
60
|
|
|
55
61
|
/** Thrown by {@link encode} for a value it will not encode (a function or a symbol). */
|
|
@@ -223,6 +229,11 @@ export function decode(wire: Wire, limits: WireDecodeLimits = {}): unknown {
|
|
|
223
229
|
DEFAULT_WIRE_DECODE_LIMITS.maxDecodedBytes,
|
|
224
230
|
"maxDecodedBytes",
|
|
225
231
|
)
|
|
232
|
+
const maxBigIntDigits = decodeLimit(
|
|
233
|
+
limits.maxBigIntDigits,
|
|
234
|
+
DEFAULT_WIRE_DECODE_LIMITS.maxBigIntDigits,
|
|
235
|
+
"maxBigIntDigits",
|
|
236
|
+
)
|
|
226
237
|
if (nodes.length > maxNodes) throw new WireDecodeError("wire node limit exceeded")
|
|
227
238
|
const built = new Map<number, unknown>()
|
|
228
239
|
let collectionEntries = 0
|
|
@@ -279,6 +290,9 @@ export function decode(wire: Wire, limits: WireDecodeLimits = {}): unknown {
|
|
|
279
290
|
if (typeof record.v !== "string" || !/^-?(?:0|[1-9][0-9]*)$/u.test(record.v)) {
|
|
280
291
|
throw new WireDecodeError("malformed bigint token")
|
|
281
292
|
}
|
|
293
|
+
if (record.v.length - (record.v.charCodeAt(0) === 45 ? 1 : 0) > maxBigIntDigits) {
|
|
294
|
+
throw new WireDecodeError("wire bigint digit limit exceeded")
|
|
295
|
+
}
|
|
282
296
|
chargeString(record.v)
|
|
283
297
|
return BigInt(record.v)
|
|
284
298
|
}
|
package/src/ws.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
attachWebSocket,
|
|
26
26
|
handshakeUpgradeStep,
|
|
27
27
|
TopicRegistry,
|
|
28
|
+
webSocketSender,
|
|
28
29
|
wrapWebSocketMessageValidation,
|
|
29
30
|
} from "./server/websocket.ts"
|
|
30
31
|
import { createBunWsHandlers } from "./server/ws-bun.ts"
|
|
@@ -37,6 +38,7 @@ const WS_RUNTIME: WsRuntime = {
|
|
|
37
38
|
createTopics: () => new TopicRegistry(),
|
|
38
39
|
bunHandlers: createBunWsHandlers,
|
|
39
40
|
attach: attachWebSocket,
|
|
41
|
+
sender: webSocketSender,
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** The install seam a server exposes so the `websocket()` plugin can hand it the runtime. */
|