diff --git a/node/frepw.js b/node/frepw.js
new file mode 100755
index 0000000000000000000000000000000000000000..da2bbf5c7a475e73cf3c46c506285edc321a6453
--- /dev/null
+++ b/node/frepw.js
@@ -0,0 +1,350 @@
+#!/usr/bin/env node
+//
+// frepw.js
+//    functional representation solver, worker version
+//
+// usage:
+//    pcb.py | node --experimental-worker frepw.js [dpi [filename]]
+// with:
+//    https://gitlab.cba.mit.edu/pub/libraries/blob/master/python/pcb.py
+//
+// Neil Gershenfeld 12/9/18
+// (c) Massachusetts Institute of Technology 2018
+//
+// This work may be reproduced, modified, distributed,
+// performed, and displayed for any purpose, but must
+// acknowledge this project. Copyright is retained and
+// must be preserved. The work is provided as is; no
+// warranty is provided, and users accept all liability.
+//
+const fs = require("fs")
+const os = require("os")
+const {Worker} = require('worker_threads')
+//
+// get frep from stdin
+//
+var input = ''
+process.stdin.on('readable',() => {
+   var chunk = process.stdin.read()
+   if (chunk != null)
+      input += chunk
+   })
+process.stdin.on('end',() => {
+   render(JSON.parse(input))
+   })
+//
+// render function
+//
+function render(frep) {
+   //
+   // check arguments
+   //
+   if (frep.zmin != frep.zmax) {
+      console.log('> 2D not (yet) supported')
+      process.exit()
+      }
+   if (frep.type != 'RGB') {
+      console.log('types other than RGB not (yet) supported')
+      process.exit()
+      }
+   if (process.argv.length == 2) {
+      var dpi = 100
+      var filename = 'out.png'
+      console.log('output to out.png at 100 DPI')
+      }
+   else if (process.argv.length == 3) {
+      var dpi = parseInt(process.argv[2])
+      var filename = 'out.png'
+      console.log('output to out.png at '+dpi+' DPI')
+      }
+   else if (process.argv.length == 4) {
+      var dpi = parseInt(process.argv[2])
+      var filename = process.argv[3]
+      console.log('output to '+filename+' at '+dpi+' DPI')
+      }
+   //
+   // set variables
+   //
+   var delta = (25.4/dpi)/frep.mm_per_unit
+   var width = Math.floor(1+(frep.xmax-frep.xmin)/delta)
+   var height = Math.floor(1+(frep.ymax-frep.ymin)/delta)
+   var buf = new SharedArrayBuffer(width*height*4)
+   var data = new Uint8Array(buf)
+   //
+   // worker thread
+   //
+   var thread =
+      `
+      const Worker = require('worker_threads')
+      Worker.parentPort.on('message',(msg) => {
+         var fn = Function('X','Y','Z','return('+msg.fn+')')
+         var data = new Uint8Array(msg.buf)
+         var width = msg.width
+         var height = msg.height
+         var xmin = msg.xmin
+         var ymin = msg.ymin
+         var delta = msg.delta
+         var z = msg.z
+         var index = msg.index
+         var workers = msg.workers
+         var start = Math.round(width*index/workers)
+         var stop = Math.round(width*(index+1)/workers)-1
+         console.log(index+': '+start+'-'+stop)
+         var x,y,f
+         for (let row = 0; row < height; ++row) {
+            y = ymin+(height-1-row)*delta
+            for (let col = start; col <= stop; ++col) {
+               x = xmin+col*delta
+               f = fn(x,y,z)
+               data[row*width*4+col*4+0] = (f & 255)
+               data[row*width*4+col*4+1] = ((f >> 8) & 255)
+               data[row*width*4+col*4+2] = ((f >> 16) & 255)
+               data[row*width*4+col*4+3] = 255
+               }
+            }
+         Worker.parentPort.postMessage(index)
+      })
+      `
+   //
+   // start workers
+   //
+   var workers = os.cpus().length
+   console.log('start '+workers+' workers')
+   var count = 0
+   for (let i = 0; i < workers; ++i) {
+      var worker = new Worker(thread,{eval:true})
+      //
+      // worker message handler
+      //
+      worker.on('message',(msg) => {
+         console.log('done: '+msg)
+         count += 1
+         //
+         // check if done
+         //
+         if (count == workers) {
+            var dpm = 1000*dpi/25.4
+            fs.writeFileSync(filename,PNG(data,width,height,dpm))
+            console.log('wrote '+width+'x'+height)
+            process.exit()
+            }
+         })
+      worker.postMessage({index:i,workers:workers,
+         delta:delta,width:width,height:height,xmin:frep.xmin,ymin:frep.ymin,z:frep.zmin,
+         buf:buf,fn:frep.function})
+      }
+   }
+//
+// PNG function
+//
+function PNG(array,width,height,dpm) {
+   //
+   // PNG buffer
+   //
+   var length =
+   8 // signature
+   + 25 // IHDR
+   + 21 // pHYs
+   + 12 // IDAT length, type, CRC
+   + 2 // zlib header
+   + 4 // zlib checksum
+   + 5*(Math.ceil((height+width*height*4)/0xffff)) // block headers
+   + width*height*4 // pixels
+   + height // scanline filters
+   + 12 // IEND
+   var buf = new ArrayBuffer(length)
+   var arr = new Uint8Array(buf)
+   var view = new DataView(buf)
+   //
+   // checksum functions
+   //
+   var CRC32table = []
+   for (let n = 0; n < 256; ++n) {
+      var crc = new Uint32Array(1)
+      crc[0] = n
+      for (let i = 0; i < 8; ++i) {
+         if (crc[0] & 1)
+            crc[0] = 0xedb88320 ^ (crc[0] >>> 1)
+         else
+            crc[0] = crc[0] >>> 1
+         }
+      CRC32table[n] = crc[0]
+      }
+   function CRC32(buf,start,stop) {
+      var crc = new Uint32Array(1)
+      crc[0] = 0xffffffff
+      for (let i = start; i < stop; ++i)
+         crc[0] = CRC32table[(crc[0] ^ buf[i]) & 0xff] ^ (crc[0] >>> 8);
+      crc[0] = crc[0] ^ 0xffffffff
+      return(crc[0])
+      }
+   var CRCAdler32 = new Uint32Array([1,0,0])
+   function Adler32(byte,crc) {
+      crc[0] = (crc[0]+byte)%65521
+      crc[1] = (crc[1]+crc[0])%65521
+      crc[2] = (crc[1]<<16)+crc[0]
+      return(crc)
+      }
+   //
+   var ptr = 0
+   var LengthPtr,DataStart,CRCstart,AdlerStart
+   //
+   // signature
+   //
+   arr.set([137,80,78,71,13,10,26,10],ptr)
+   ptr += 8
+   //
+   // IHDR length
+   //
+   view.setUint32(ptr,13,false)
+   ptr += 4
+   //
+   // IHDR type
+   //
+   CRCstart = ptr
+   arr.set([73,72,68,82],ptr)
+   ptr += 4
+   //
+   // IHDR data
+   //
+   view.setUint32(ptr,width,false) // width
+   ptr += 4
+   view.setUint32(ptr,height,false) // height
+   ptr += 4
+   arr.set([8,6,0,0,0],ptr)// 8 bit depth, RGBA, deflate compression, adaptive filter
+   ptr += 5
+   //
+   // IHDR CRC
+   //
+   view.setUint32(ptr,CRC32(arr,CRCstart,ptr),false)
+   ptr += 4
+   //
+   // pHYs length
+   //
+   view.setUint32(ptr,9,false)
+   ptr += 4
+   //
+   // pHYs type
+   //
+   CRCstart = ptr
+   arr.set([112,72,89,115],ptr)
+   ptr += 4
+   //
+   // pHYs data
+   //
+   view.setUint32(ptr,dpm) // x pixels per unit
+   ptr += 4
+   view.setUint32(ptr,dpm) // y pixels per unit
+   ptr += 4
+   view.setUint8(ptr,1) // meter unit
+   ptr += 1
+   //
+   // pHYs CRC
+   //
+   view.setUint32(ptr,CRC32(arr,CRCstart,ptr),false)
+   ptr += 4
+   //
+   // IDAT length location
+   //
+   LengthPtr = ptr
+   ptr += 4
+   //
+   // IDAT type
+   //
+   CRCstart = ptr
+   arr.set([73,68,65,84],ptr)
+   ptr += 4
+   //
+   // IDAT data
+   //
+   // IDAT data zlib header
+   //
+   DataStart = ptr
+   arr.set([0x78,0x01],ptr)
+   ptr += 2
+   //
+   // IDAT data deflate blocks
+   //
+   var BlockSize = 0xffff
+   var BlockByte = 0
+   var DataSize = height+width*height*4
+   var DataByte = 0
+   function BlockData(byte) {
+      if (BlockByte == 0) { // start of new block
+         if (DataByte+BlockSize < DataSize) {
+            var BlockLength = BlockSize
+            view.setUint8(ptr,0) // block header, not last block, no compression
+            ptr += 1
+            }
+         else if (DataByte+BlockSize == DataSize) {
+            var BlockLength = BlockSize
+            view.setUint8(ptr,1) // block header, last block, no compression
+            ptr += 1
+            }
+         else {
+            var BlockLength = DataSize-DataByte
+            view.setUint8(ptr,1) // block header, last block, no compression
+            ptr += 1
+            }
+         view.setUint16(ptr,BlockLength,true) // block length, little-endian
+         ptr += 2
+         view.setUint16(ptr,0xffff ^ view.getUint16(ptr-2,true),true) // block length one's complement
+         ptr += 2
+         }
+      CRCAdler32 = Adler32(byte,CRCAdler32) // update zlib checksum
+      view.setUint8(ptr,byte) // save byte to PNG buffer
+      ptr += 1 // move PNG pointer
+      DataByte += 1 // move data counter
+      BlockByte += 1 // move block counter
+      if (BlockByte == BlockSize) // end of deflate block
+         BlockByte = 0 // next byte starts new block
+      }
+   //
+   // IDAT data scan lines
+   //
+   for (let row = 0; row < height; ++row) {
+      BlockData(0) // scan line, no filter
+      for (let col = 0; col < width; ++col) {
+         BlockData(array[row*width*4+col*4+0]) // R
+         BlockData(array[row*width*4+col*4+1]) // G
+         BlockData(array[row*width*4+col*4+2]) // B
+         BlockData(array[row*width*4+col*4+3]) // A
+         }
+      }
+   //
+   // IDAT data zlib checksum
+   //
+   view.setUint32(ptr,CRCAdler32[2],false) // zlib Adler32
+   ptr += 4
+   //
+   // set IDAT length
+   //
+   view.setUint32(LengthPtr,ptr-DataStart,false)
+   //
+   // IDAT CRC
+   //
+   view.setUint32(ptr,CRC32(arr,CRCstart,ptr),false)
+   ptr += 4
+   //
+   // IEND length
+   //
+   view.setUint32(ptr,0,false)
+   ptr += 4
+   //
+   // IEND type
+   //
+   CRCstart = ptr
+   arr.set([73,69,78,68],ptr)
+   ptr += 4
+   //
+   // no IEND data
+   //
+   // IHDR CRC
+   //
+   view.setUint32(ptr,CRC32(arr,CRCstart,ptr),false)
+   ptr += 4
+   //
+   // return
+   //
+   return arr
+   }