tranfi 0.0.2 → 0.1.2

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.
Files changed (108) hide show
  1. package/README.md +395 -0
  2. package/app/assets/index-6quYZ5Ap.css +5 -0
  3. package/app/assets/index-pDFMluyz.js +160 -0
  4. package/app/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  5. package/app/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  6. package/app/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  7. package/app/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  8. package/app/index.html +13 -0
  9. package/binding.gyp +69 -0
  10. package/csrc/arena.c +91 -0
  11. package/csrc/batch.c +229 -0
  12. package/csrc/buffer.c +78 -0
  13. package/csrc/cJSON.c +3143 -0
  14. package/csrc/cJSON.h +300 -0
  15. package/csrc/codec_csv.c +1058 -0
  16. package/csrc/codec_jsonl.c +374 -0
  17. package/csrc/codec_table.c +218 -0
  18. package/csrc/codec_text.c +229 -0
  19. package/csrc/compiler.c +102 -0
  20. package/csrc/date_utils.h +94 -0
  21. package/csrc/dsl.c +1180 -0
  22. package/csrc/dsl.h +22 -0
  23. package/csrc/expr.c +1245 -0
  24. package/csrc/expr.h +56 -0
  25. package/csrc/internal.h +250 -0
  26. package/csrc/ir.c +119 -0
  27. package/csrc/ir.h +167 -0
  28. package/csrc/ir_schema.c +60 -0
  29. package/csrc/ir_serialize.c +104 -0
  30. package/csrc/ir_sql.c +1211 -0
  31. package/csrc/ir_validate.c +120 -0
  32. package/csrc/main.c +392 -0
  33. package/csrc/op_acf.c +133 -0
  34. package/csrc/op_anomaly.c +120 -0
  35. package/csrc/op_bin.c +109 -0
  36. package/csrc/op_cast.c +195 -0
  37. package/csrc/op_clip.c +88 -0
  38. package/csrc/op_date_trunc.c +181 -0
  39. package/csrc/op_datetime.c +212 -0
  40. package/csrc/op_derive.c +248 -0
  41. package/csrc/op_diff.c +134 -0
  42. package/csrc/op_ewma.c +103 -0
  43. package/csrc/op_explode.c +108 -0
  44. package/csrc/op_fill_down.c +163 -0
  45. package/csrc/op_fill_null.c +123 -0
  46. package/csrc/op_filter.c +132 -0
  47. package/csrc/op_frequency.c +193 -0
  48. package/csrc/op_grep.c +163 -0
  49. package/csrc/op_group_agg.c +285 -0
  50. package/csrc/op_hash.c +126 -0
  51. package/csrc/op_head.c +149 -0
  52. package/csrc/op_interpolate.c +239 -0
  53. package/csrc/op_join.c +384 -0
  54. package/csrc/op_label_encode.c +144 -0
  55. package/csrc/op_lead.c +190 -0
  56. package/csrc/op_normalize.c +226 -0
  57. package/csrc/op_onehot.c +185 -0
  58. package/csrc/op_pivot.c +370 -0
  59. package/csrc/op_registry.c +1148 -0
  60. package/csrc/op_rename.c +138 -0
  61. package/csrc/op_replace.c +202 -0
  62. package/csrc/op_sample.c +101 -0
  63. package/csrc/op_select.c +140 -0
  64. package/csrc/op_skip.c +152 -0
  65. package/csrc/op_sort.c +273 -0
  66. package/csrc/op_split.c +114 -0
  67. package/csrc/op_split_data.c +87 -0
  68. package/csrc/op_stack.c +315 -0
  69. package/csrc/op_stats.c +779 -0
  70. package/csrc/op_step.c +171 -0
  71. package/csrc/op_tail.c +96 -0
  72. package/csrc/op_top.c +150 -0
  73. package/csrc/op_trim.c +109 -0
  74. package/csrc/op_unique.c +300 -0
  75. package/csrc/op_unpivot.c +159 -0
  76. package/csrc/op_validate.c +71 -0
  77. package/csrc/op_window.c +150 -0
  78. package/csrc/pipeline.c +315 -0
  79. package/csrc/plan.c +206 -0
  80. package/csrc/recipes.c +102 -0
  81. package/csrc/recipes.h +27 -0
  82. package/csrc/report.c +463 -0
  83. package/csrc/report.h +22 -0
  84. package/csrc/tranfi.h +123 -0
  85. package/csrc/wasm_api.c +157 -0
  86. package/napi_api.c +326 -0
  87. package/package.json +46 -57
  88. package/src/cli.js +193 -0
  89. package/src/engines/duckdb.js +109 -0
  90. package/src/index.js +306 -0
  91. package/src/native.js +22 -0
  92. package/src/pipeline.js +286 -0
  93. package/src/server.js +277 -0
  94. package/src/wasm.js +19 -0
  95. package/wasm/index.js +244 -0
  96. package/wasm/package.json +1 -0
  97. package/wasm/tranfi_core.js +0 -0
  98. package/LICENSE +0 -21
  99. package/dist/bundle.js +0 -1
  100. package/index.html +0 -18
  101. package/src/app.css +0 -169
  102. package/src/app.js +0 -203
  103. package/src/app.vue +0 -250
  104. package/src/bulma-input.vue +0 -110
  105. package/src/common-inputs.js +0 -28
  106. package/src/main.js +0 -20
  107. package/src/transforms.js +0 -166
  108. package/webpack.config.js +0 -108
@@ -0,0 +1,286 @@
1
+ /**
2
+ * pipeline.js — Pipeline builder and runner.
3
+ *
4
+ * Tries native N-API addon first, falls back to WASM.
5
+ */
6
+
7
+ import { readFile, writeFile } from 'fs/promises'
8
+ import { existsSync } from 'fs'
9
+ import nativeBinding from './native.js'
10
+
11
+ // Channel IDs (match tranfi.h)
12
+ const CHAN_MAIN = 0
13
+ const CHAN_ERRORS = 1
14
+ const CHAN_STATS = 2
15
+ const CHAN_SAMPLES = 3
16
+
17
+ const CHUNK_SIZE = 64 * 1024
18
+
19
+ /**
20
+ * Get the backend (native or WASM).
21
+ * Returns an object with: createPipeline, push, finish, pull, free, version
22
+ */
23
+ let _backend = null
24
+
25
+ async function getBackend() {
26
+ if (_backend) return _backend
27
+
28
+ // Try native first
29
+ if (nativeBinding) {
30
+ _backend = nativeBinding
31
+ return _backend
32
+ }
33
+
34
+ // Fall back to WASM
35
+ const { loadWasm } = await import('./wasm.js')
36
+ const wasm = await loadWasm()
37
+
38
+ _backend = {
39
+ compileDsl(dsl) {
40
+ const len = wasm.lengthBytesUTF8(dsl)
41
+ const ptr = wasm._malloc(len + 1)
42
+ wasm.stringToUTF8(dsl, ptr, len + 1)
43
+ const resultPtr = wasm.ccall('wasm_compile_dsl', 'number', ['number', 'number'], [ptr, len])
44
+ wasm._free(ptr)
45
+ if (!resultPtr) {
46
+ const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [-1])
47
+ throw new Error(`DSL parse failed: ${err || 'unknown error'}`)
48
+ }
49
+ const json = wasm.UTF8ToString(resultPtr)
50
+ wasm._free(resultPtr)
51
+ return json
52
+ },
53
+ createPipeline(planJson) {
54
+ const len = wasm.lengthBytesUTF8(planJson)
55
+ const ptr = wasm._malloc(len + 1)
56
+ wasm.stringToUTF8(planJson, ptr, len + 1)
57
+ const handle = wasm.ccall('wasm_pipeline_create', 'number', ['number', 'number'], [ptr, len])
58
+ wasm._free(ptr)
59
+ if (handle < 0) {
60
+ const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
61
+ throw new Error(`Failed to create pipeline: ${err || 'unknown error'}`)
62
+ }
63
+ return handle
64
+ },
65
+ push(handle, buffer) {
66
+ const len = buffer.length
67
+ const ptr = wasm._malloc(len)
68
+ wasm.HEAPU8.set(buffer, ptr)
69
+ const rc = wasm.ccall('wasm_pipeline_push', 'number', ['number', 'number', 'number'], [handle, ptr, len])
70
+ wasm._free(ptr)
71
+ if (rc !== 0) {
72
+ const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
73
+ throw new Error(`Push failed: ${err || 'unknown error'}`)
74
+ }
75
+ },
76
+ finish(handle) {
77
+ const rc = wasm.ccall('wasm_pipeline_finish', 'number', ['number'], [handle])
78
+ if (rc !== 0) {
79
+ const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [handle])
80
+ throw new Error(`Finish failed: ${err || 'unknown error'}`)
81
+ }
82
+ },
83
+ pull(handle, channel) {
84
+ const bufSize = 65536
85
+ const ptr = wasm._malloc(bufSize)
86
+ const chunks = []
87
+ for (;;) {
88
+ const n = wasm.ccall('wasm_pipeline_pull', 'number',
89
+ ['number', 'number', 'number', 'number'], [handle, channel, ptr, bufSize])
90
+ if (n === 0) break
91
+ chunks.push(new Uint8Array(wasm.HEAPU8.buffer, ptr, n).slice())
92
+ }
93
+ wasm._free(ptr)
94
+ // Concatenate
95
+ const total = chunks.reduce((s, c) => s + c.length, 0)
96
+ const result = Buffer.alloc(total)
97
+ let offset = 0
98
+ for (const c of chunks) {
99
+ result.set(c, offset)
100
+ offset += c.length
101
+ }
102
+ return result
103
+ },
104
+ free(handle) {
105
+ wasm.ccall('wasm_pipeline_free', null, ['number'], [handle])
106
+ },
107
+ version() {
108
+ return wasm.ccall('wasm_version', 'string', [], [])
109
+ },
110
+ recipeCount() {
111
+ return wasm.ccall('wasm_recipe_count', 'number', [], [])
112
+ },
113
+ recipeName(index) {
114
+ return wasm.ccall('wasm_recipe_name', 'string', ['number'], [index])
115
+ },
116
+ recipeDsl(index) {
117
+ return wasm.ccall('wasm_recipe_dsl', 'string', ['number'], [index])
118
+ },
119
+ recipeDescription(index) {
120
+ return wasm.ccall('wasm_recipe_description', 'string', ['number'], [index])
121
+ },
122
+ recipeFindDsl(name) {
123
+ const len = wasm.lengthBytesUTF8(name)
124
+ const ptr = wasm._malloc(len + 1)
125
+ wasm.stringToUTF8(name, ptr, len + 1)
126
+ const result = wasm.ccall('wasm_recipe_find_dsl', 'string', ['number'], [ptr])
127
+ wasm._free(ptr)
128
+ return result || null
129
+ },
130
+ compileToSql(dsl) {
131
+ const len = wasm.lengthBytesUTF8(dsl)
132
+ const ptr = wasm._malloc(len + 1)
133
+ wasm.stringToUTF8(dsl, ptr, len + 1)
134
+ const resultPtr = wasm.ccall('wasm_compile_to_sql', 'number', ['number', 'number'], [ptr, len])
135
+ wasm._free(ptr)
136
+ if (!resultPtr) {
137
+ const err = wasm.ccall('wasm_pipeline_error', 'string', ['number'], [-1])
138
+ throw new Error(`SQL compile failed: ${err || 'unknown error'}`)
139
+ }
140
+ const sql = wasm.UTF8ToString(resultPtr)
141
+ wasm._free(resultPtr)
142
+ return sql
143
+ }
144
+ }
145
+
146
+ return _backend
147
+ }
148
+
149
+
150
+ export class PipelineResult {
151
+ constructor(output, errors, stats, samples) {
152
+ this.output = output
153
+ this.errors = errors
154
+ this.stats = stats
155
+ this.samples = samples
156
+ }
157
+
158
+ get outputText() {
159
+ return this.output.toString('utf-8')
160
+ }
161
+
162
+ get statsText() {
163
+ return this.stats.toString('utf-8')
164
+ }
165
+
166
+ toString() {
167
+ return `PipelineResult(output=${this.output.length} bytes, errors=${this.errors.length} bytes, stats=${this.stats.length} bytes)`
168
+ }
169
+ }
170
+
171
+
172
+ export class Pipeline {
173
+ constructor(steps, { planJson, engine } = {}) {
174
+ this._engine = engine || null
175
+ if (planJson) {
176
+ this._planJson = planJson
177
+ this._dsl = null
178
+ this._steps = null
179
+ } else if (typeof steps === 'string') {
180
+ this._dsl = steps
181
+ this._steps = null
182
+ this._planJson = null
183
+ } else {
184
+ this._dsl = null
185
+ this._steps = steps
186
+ this._planJson = null
187
+ }
188
+ }
189
+
190
+ async _toPlanJson(backend) {
191
+ if (this._planJson) return this._planJson
192
+ if (this._dsl) {
193
+ // Check if it's a built-in recipe name (no pipes, no braces)
194
+ const s = this._dsl.trim()
195
+ if (!s.includes('|') && !s.startsWith('{')) {
196
+ const recipeDsl = backend.recipeFindDsl(s)
197
+ if (recipeDsl) return backend.compileDsl(recipeDsl)
198
+ }
199
+ return backend.compileDsl(this._dsl)
200
+ }
201
+ return JSON.stringify({ steps: this._steps })
202
+ }
203
+
204
+ async run({ input, inputFile } = {}) {
205
+ if (this._engine && this._engine !== 'native') {
206
+ return this._runEngine({ input, inputFile })
207
+ }
208
+
209
+ const backend = await getBackend()
210
+ const planJson = await this._toPlanJson(backend)
211
+ const handle = backend.createPipeline(planJson)
212
+
213
+ try {
214
+ if (inputFile) {
215
+ const data = await readFile(inputFile)
216
+ // Push in chunks
217
+ for (let i = 0; i < data.length; i += CHUNK_SIZE) {
218
+ const chunk = data.subarray(i, Math.min(i + CHUNK_SIZE, data.length))
219
+ backend.push(handle, chunk)
220
+ }
221
+ } else if (input) {
222
+ const buf = typeof input === 'string' ? Buffer.from(input, 'utf-8') : input
223
+ backend.push(handle, buf)
224
+ }
225
+
226
+ backend.finish(handle)
227
+
228
+ const output = backend.pull(handle, CHAN_MAIN)
229
+ const errors = backend.pull(handle, CHAN_ERRORS)
230
+ const stats = backend.pull(handle, CHAN_STATS)
231
+ const samples = backend.pull(handle, CHAN_SAMPLES)
232
+
233
+ return new PipelineResult(output, errors, stats, samples)
234
+ } finally {
235
+ backend.free(handle)
236
+ }
237
+ }
238
+
239
+ async _runEngine({ input, inputFile } = {}) {
240
+ const { getEngine } = await import('./engines/duckdb.js')
241
+ const engine = getEngine(this._engine)
242
+ const dsl = this._dsl
243
+ if (!dsl) throw new Error('DuckDB engine requires a DSL string pipeline')
244
+ return engine.run(dsl, { input, inputFile })
245
+ }
246
+ }
247
+
248
+ export async function compileDsl(dsl) {
249
+ const backend = await getBackend()
250
+ return backend.compileDsl(dsl)
251
+ }
252
+
253
+ export async function compileToSql(dsl) {
254
+ const backend = await getBackend()
255
+ return backend.compileToSql(dsl)
256
+ }
257
+
258
+ export async function loadRecipe(source) {
259
+ let planJson
260
+ if (typeof source === 'object' && source.steps) {
261
+ planJson = JSON.stringify(source)
262
+ } else if (typeof source === 'string' && source.trimStart().startsWith('{')) {
263
+ planJson = source
264
+ } else {
265
+ planJson = await readFile(source, 'utf-8')
266
+ }
267
+ return new Pipeline(null, { planJson })
268
+ }
269
+
270
+ export async function saveRecipe(steps, path) {
271
+ await writeFile(path, JSON.stringify({ steps }))
272
+ }
273
+
274
+ export async function recipes() {
275
+ const backend = await getBackend()
276
+ const n = backend.recipeCount()
277
+ const result = []
278
+ for (let i = 0; i < n; i++) {
279
+ result.push({
280
+ name: backend.recipeName(i),
281
+ dsl: backend.recipeDsl(i),
282
+ description: backend.recipeDescription(i)
283
+ })
284
+ }
285
+ return result
286
+ }
package/src/server.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * server.js — HTTP server for tranfi serve mode.
3
+ *
4
+ * Serves the app frontend and provides API endpoints backed by
5
+ * the native C core (N-API). No external dependencies — uses
6
+ * built-in http, fs, path, url modules only.
7
+ */
8
+
9
+ import { createServer } from 'http'
10
+ import { readFileSync, readdirSync, statSync, readFile as readFileCb, existsSync } from 'fs'
11
+ import { join, extname, resolve, basename } from 'path'
12
+ import { URL } from 'url'
13
+ import { pipeline, recipes } from './index.js'
14
+ import nativeBinding from './native.js'
15
+
16
+ const MIME_TYPES = {
17
+ '.html': 'text/html; charset=utf-8',
18
+ '.js': 'application/javascript; charset=utf-8',
19
+ '.mjs': 'application/javascript; charset=utf-8',
20
+ '.css': 'text/css; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.wasm': 'application/wasm',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.ico': 'image/x-icon',
27
+ '.woff': 'font/woff',
28
+ '.woff2': 'font/woff2',
29
+ '.ttf': 'font/ttf',
30
+ '.eot': 'application/vnd.ms-fontobject',
31
+ '.map': 'application/json'
32
+ }
33
+
34
+ const DATA_EXTENSIONS = new Set(['.csv', '.jsonl', '.tsv', '.txt', '.json'])
35
+
36
+ function esc (s) {
37
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
38
+ }
39
+
40
+ function csvToHtml (csv, label) {
41
+ if (!csv || !csv.trim()) return ''
42
+ const lines = csv.trim().split('\n')
43
+ const header = lines[0].split(',')
44
+ const body = lines.slice(1)
45
+ const maxRows = 500
46
+ const truncated = body.length > maxRows
47
+ const displayBody = truncated ? body.slice(0, maxRows) : body
48
+
49
+ let h = `<div style="font-size:12px;color:#888;margin-bottom:6px">`
50
+ h += `${esc(label)} (${body.length} rows${truncated ? ', showing first ' + maxRows : ''})</div>`
51
+ h += '<div style="overflow-x:auto;max-height:500px;overflow-y:auto">'
52
+ h += '<table style="width:100%;border-collapse:collapse;font-size:13px;font-family:monospace">'
53
+ h += '<thead><tr>'
54
+ for (const col of header) {
55
+ h += '<th style="padding:6px 12px;text-align:left;border-bottom:2px solid #ddd;'
56
+ h += 'background:#f8f8f8;font-weight:600;position:sticky;top:0">'
57
+ h += esc(col.trim()) + '</th>'
58
+ }
59
+ h += '</tr></thead><tbody>'
60
+ for (const row of displayBody) {
61
+ h += '<tr>'
62
+ for (const cell of row.split(',')) {
63
+ h += '<td style="padding:4px 12px;text-align:left;border-bottom:1px solid #eee">'
64
+ h += esc(cell.trim()) + '</td>'
65
+ }
66
+ h += '</tr>'
67
+ }
68
+ h += '</tbody></table></div>'
69
+ return h
70
+ }
71
+
72
+ function jsonBody (req) {
73
+ return new Promise((resolve, reject) => {
74
+ const chunks = []
75
+ req.on('data', c => chunks.push(c))
76
+ req.on('end', () => {
77
+ try {
78
+ resolve(JSON.parse(Buffer.concat(chunks).toString()))
79
+ } catch (e) {
80
+ reject(new Error('Invalid JSON body'))
81
+ }
82
+ })
83
+ req.on('error', reject)
84
+ })
85
+ }
86
+
87
+ function sendJson (res, data, status = 200) {
88
+ const body = JSON.stringify(data)
89
+ res.writeHead(status, {
90
+ 'Content-Type': 'application/json; charset=utf-8',
91
+ 'Access-Control-Allow-Origin': '*'
92
+ })
93
+ res.end(body)
94
+ }
95
+
96
+ function sendError (res, msg, status = 400) {
97
+ sendJson(res, { error: msg }, status)
98
+ }
99
+
100
+ function isSafeName (name) {
101
+ return name && !name.includes('..') && !name.includes('/') && !name.includes('\\')
102
+ }
103
+
104
+ export function startServer ({ port = 3000, dataDir, appDir }) {
105
+ dataDir = resolve(dataDir)
106
+ appDir = resolve(appDir)
107
+
108
+ if (!existsSync(appDir)) {
109
+ throw new Error(`App directory not found: ${appDir}`)
110
+ }
111
+ if (!existsSync(dataDir)) {
112
+ throw new Error(`Data directory not found: ${dataDir}`)
113
+ }
114
+
115
+ // Read and patch index.html once
116
+ const indexPath = join(appDir, 'index.html')
117
+ let indexHtml = ''
118
+ if (existsSync(indexPath)) {
119
+ indexHtml = readFileSync(indexPath, 'utf-8')
120
+ const config = `<script>window.__TRANFI_SERVER__={api:'/api'}</script>`
121
+ indexHtml = indexHtml.replace('</head>', config + '</head>')
122
+ }
123
+
124
+ const server = createServer(async (req, res) => {
125
+ const url = new URL(req.url, `http://${req.headers.host}`)
126
+ const pathname = url.pathname
127
+
128
+ // CORS preflight
129
+ if (req.method === 'OPTIONS') {
130
+ res.writeHead(204, {
131
+ 'Access-Control-Allow-Origin': '*',
132
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
133
+ 'Access-Control-Allow-Headers': 'Content-Type'
134
+ })
135
+ res.end()
136
+ return
137
+ }
138
+
139
+ try {
140
+ // API routes
141
+ if (pathname === '/api/files' && req.method === 'GET') {
142
+ return handleFiles(res, dataDir)
143
+ }
144
+ if (pathname === '/api/file' && req.method === 'GET') {
145
+ const name = url.searchParams.get('name')
146
+ const head = parseInt(url.searchParams.get('head') || '20', 10)
147
+ return handleFilePreview(res, dataDir, name, head)
148
+ }
149
+ if (pathname === '/api/run' && req.method === 'POST') {
150
+ const body = await jsonBody(req)
151
+ return await handleRun(res, dataDir, body)
152
+ }
153
+ if (pathname === '/api/recipes' && req.method === 'GET') {
154
+ return await handleRecipes(res)
155
+ }
156
+ if (pathname === '/api/version' && req.method === 'GET') {
157
+ const v = nativeBinding ? nativeBinding.version() : 'unknown'
158
+ return sendJson(res, { version: v })
159
+ }
160
+
161
+ // Static files
162
+ serveStatic(res, appDir, pathname, indexHtml)
163
+ } catch (err) {
164
+ sendError(res, err.message, 500)
165
+ }
166
+ })
167
+
168
+ server.listen(port, () => {
169
+ const v = nativeBinding ? nativeBinding.version() : 'unknown'
170
+ process.stderr.write(`tranfi ${v} server\n`)
171
+ process.stderr.write(` App: ${appDir}\n`)
172
+ process.stderr.write(` Data: ${dataDir}\n`)
173
+ process.stderr.write(` http://localhost:${port}\n`)
174
+ })
175
+
176
+ return server
177
+ }
178
+
179
+ function handleFiles (res, dataDir) {
180
+ const entries = readdirSync(dataDir)
181
+ const files = []
182
+ for (const name of entries) {
183
+ const ext = extname(name).toLowerCase()
184
+ if (!DATA_EXTENSIONS.has(ext)) continue
185
+ try {
186
+ const st = statSync(join(dataDir, name))
187
+ if (st.isFile()) {
188
+ files.push({ name, size: st.size })
189
+ }
190
+ } catch {}
191
+ }
192
+ files.sort((a, b) => a.name.localeCompare(b.name))
193
+ sendJson(res, { files })
194
+ }
195
+
196
+ function handleFilePreview (res, dataDir, name, head) {
197
+ if (!isSafeName(name)) return sendError(res, 'Invalid file name')
198
+ const filePath = join(dataDir, name)
199
+ if (!existsSync(filePath)) return sendError(res, 'File not found', 404)
200
+
201
+ const content = readFileSync(filePath, 'utf-8')
202
+ const lines = content.split('\n')
203
+ const preview = lines.slice(0, head).join('\n')
204
+ sendJson(res, { preview, lines: lines.length })
205
+ }
206
+
207
+ async function handleRun (res, dataDir, body) {
208
+ const { file, dsl } = body
209
+ if (!dsl) return sendError(res, 'Missing dsl field')
210
+ if (!file) return sendError(res, 'Missing file field')
211
+ if (!isSafeName(file)) return sendError(res, 'Invalid file name')
212
+
213
+ const filePath = join(dataDir, file)
214
+ if (!existsSync(filePath)) return sendError(res, `File not found: ${file}`, 404)
215
+
216
+ const p = pipeline(dsl)
217
+ const result = await p.run({ inputFile: filePath })
218
+
219
+ const output = result.outputText
220
+ const stats = result.statsText
221
+
222
+ sendJson(res, {
223
+ output: csvToHtml(output, 'Output'),
224
+ stats: csvToHtml(stats, 'Stats')
225
+ })
226
+ }
227
+
228
+ async function handleRecipes (res) {
229
+ const list = await recipes()
230
+ sendJson(res, { recipes: list })
231
+ }
232
+
233
+ function serveStatic (res, appDir, pathname, indexHtml) {
234
+ // Normalize path
235
+ let filePath = join(appDir, pathname === '/' ? 'index.html' : pathname)
236
+
237
+ // Security: ensure resolved path is within appDir
238
+ const resolved = resolve(filePath)
239
+ if (!resolved.startsWith(appDir)) {
240
+ res.writeHead(403)
241
+ res.end('Forbidden')
242
+ return
243
+ }
244
+
245
+ // SPA fallback: if file doesn't exist, serve index.html
246
+ if (!existsSync(resolved) || statSync(resolved).isDirectory()) {
247
+ if (indexHtml) {
248
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
249
+ res.end(indexHtml)
250
+ } else {
251
+ res.writeHead(404)
252
+ res.end('Not found')
253
+ }
254
+ return
255
+ }
256
+
257
+ // Serve index.html with injected config
258
+ if (basename(resolved) === 'index.html' && indexHtml) {
259
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
260
+ res.end(indexHtml)
261
+ return
262
+ }
263
+
264
+ // Serve regular file
265
+ const ext = extname(resolved).toLowerCase()
266
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
267
+
268
+ readFileCb(resolved, (err, data) => {
269
+ if (err) {
270
+ res.writeHead(500)
271
+ res.end('Internal error')
272
+ return
273
+ }
274
+ res.writeHead(200, { 'Content-Type': contentType })
275
+ res.end(data)
276
+ })
277
+ }
package/src/wasm.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * wasm.js — Load the WASM module.
3
+ * Lazy-initializes on first use.
4
+ */
5
+
6
+ let instance = null
7
+
8
+ export async function loadWasm() {
9
+ if (instance) return instance
10
+
11
+ // Try to load the WASM module
12
+ const { default: createTranfi } = await import('../wasm/tranfi_core.js')
13
+ instance = await createTranfi()
14
+ return instance
15
+ }
16
+
17
+ export function getWasm() {
18
+ return instance
19
+ }