1010 *
1111 * Wire contract (`/v1/worker`): one JSON object per text frame. The first
1212 * client frame is `{ type: 'auth', token, env }`, where `env` carries the
13- * worker limits the app enforces; after it, frames are the objects the worker
13+ * worker settings the app owns (limits, timezone, locale); after it, frames
14+ * are the objects the worker
1415 * exchanges over IPC. Host-originated frames are `{ type: 'host:heartbeat' }`
1516 * every 30s and `{ type: 'host:draining' }` on SIGTERM, after which the app
1617 * retires the connection once its executions finish. A breaking change to the
@@ -40,8 +41,14 @@ const MAX_STARTUP_STDERR_CHARS = 64 * 1024
4041const MIN_TOKEN_LENGTH = 32
4142const UNAUTHORIZED_CLOSE_CODE = 4401
4243const ALLOWED_INBOUND_TYPES = new Set ( [ 'execute' , 'cancel' , 'fetchResponse' , 'brokerResponse' ] )
43- /** Worker limits a client may set, each a decimal integer. */
44- const CLIENT_WORKER_ENV_KEYS = new Set ( [ 'IVM_MAX_STDOUT_CHARS' , 'IVM_MAX_FETCH_OPTIONS_JSON_CHARS' ] )
44+ /** Worker settings a client may set, each with the only shape it accepts. */
45+ const CLIENT_WORKER_ENV = {
46+ IVM_MAX_STDOUT_CHARS : / ^ \d { 1 , 12 } $ / ,
47+ IVM_MAX_FETCH_OPTIONS_JSON_CHARS : / ^ \d { 1 , 12 } $ / ,
48+ TZ : / ^ : ? [ A - Z a - z 0 - 9 _ + \- / ] { 1 , 64 } $ / ,
49+ LANG : / ^ [ A - Z a - z 0 - 9 _ . @ - ] { 1 , 64 } $ / ,
50+ LC_ALL : / ^ [ A - Z a - z 0 - 9 _ . @ - ] { 1 , 64 } $ / ,
51+ }
4552
4653function readPositiveInt ( name , fallback ) {
4754 const parsed = Number . parseInt ( process . env [ name ] || '' , 10 )
@@ -62,6 +69,8 @@ if (process.versions.bun) {
6269
6370const PORT = readPositiveInt ( 'IVM_HOST_PORT' , 3100 )
6471const MAX_WORKERS = readPositiveInt ( 'IVM_HOST_MAX_WORKERS' , 16 )
72+ /** Unauthenticated connections are bounded on their own, so they never hold a worker slot. */
73+ const MAX_PENDING_AUTH = MAX_WORKERS * 4
6574const MAX_MESSAGE_BYTES = readPositiveInt ( 'IVM_HOST_MAX_MESSAGE_BYTES' , 64 * 1024 * 1024 )
6675const DRAIN_TIMEOUT_MS = readPositiveInt ( 'IVM_HOST_DRAIN_TIMEOUT_MS' , 110000 )
6776
@@ -87,22 +96,18 @@ function isAuthorized(presented) {
8796/**
8897 * Mirrors `buildWorkerEnv()` in `isolated-vm.ts`: the worker runs untrusted
8998 * code and receives an explicit allowlist, never this process's environment.
90- * The limits come from the client so a remote worker enforces the app's values.
99+ * Settings the app owns come from the client, so a remote worker behaves like
100+ * a local one.
91101 */
92102function buildWorkerEnv ( clientEnv ) {
93103 const workerEnv = { }
94- for ( const key of [ 'PATH' , 'NODE_ENV' , 'TZ' , 'LANG' , 'LC_ALL' ] ) {
104+ for ( const key of [ 'PATH' , 'NODE_ENV' ] ) {
95105 if ( process . env [ key ] !== undefined ) workerEnv [ key ] = process . env [ key ]
96106 }
97107 if ( clientEnv && typeof clientEnv === 'object' ) {
98- for ( const [ key , value ] of Object . entries ( clientEnv ) ) {
99- if (
100- CLIENT_WORKER_ENV_KEYS . has ( key ) &&
101- typeof value === 'string' &&
102- / ^ \d { 1 , 12 } $ / . test ( value )
103- ) {
104- workerEnv [ key ] = value
105- }
108+ for ( const [ key , pattern ] of Object . entries ( CLIENT_WORKER_ENV ) ) {
109+ const value = clientEnv [ key ]
110+ if ( typeof value === 'string' && pattern . test ( value ) ) workerEnv [ key ] = value
106111 }
107112 }
108113 return workerEnv
@@ -154,23 +159,30 @@ const wss = new WebSocketServer({
154159
155160server . on ( 'upgrade' , ( req , socket , head ) => {
156161 socket . on ( 'error' , ( ) => socket . destroy ( ) )
157- if ( new URL ( req . url || '/' , 'http://host' ) . pathname !== WORKER_PATH ) {
162+ let pathname
163+ try {
164+ pathname = new URL ( req . url || '/' , 'http://host' ) . pathname
165+ } catch {
166+ rejectUpgrade ( socket , 400 , 'Bad Request' )
167+ return
168+ }
169+ if ( pathname !== WORKER_PATH ) {
158170 rejectUpgrade ( socket , 404 , 'Not Found' )
159171 return
160172 }
161173 if ( draining ) {
162174 rejectUpgrade ( socket , 503 , 'Service Unavailable' )
163175 return
164176 }
165- if ( workers . size + pendingConnections >= MAX_WORKERS ) {
177+ if ( workers . size >= MAX_WORKERS || pendingConnections >= MAX_PENDING_AUTH ) {
166178 log ( 'warn' , 'Rejected worker connection at capacity' , { maxWorkers : MAX_WORKERS } )
167179 rejectUpgrade ( socket , 503 , 'Service Unavailable' )
168180 return
169181 }
170182 wss . handleUpgrade ( req , socket , head , ( ws ) => authenticate ( ws ) )
171183} )
172184
173- /** Holds a capacity slot until the client's first frame proves it holds the token. */
185+ /** Admits the connection to a worker slot only once its first frame proves it holds the token. */
174186function authenticate ( ws ) {
175187 pendingConnections ++
176188 let settled = false
@@ -206,6 +218,11 @@ function authenticate(ws) {
206218 ws . close ( 1012 , 'draining' )
207219 return
208220 }
221+ if ( workers . size >= MAX_WORKERS ) {
222+ log ( 'warn' , 'Rejected worker connection at capacity' , { maxWorkers : MAX_WORKERS } )
223+ ws . close ( 1013 , 'at capacity' )
224+ return
225+ }
209226 ws . off ( 'error' , onPreAuthError )
210227 attachWorker ( ws , buildWorkerEnv ( message . env ) )
211228 } )
0 commit comments