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.
- package/README.md +395 -0
- package/app/assets/index-6quYZ5Ap.css +5 -0
- package/app/assets/index-pDFMluyz.js +160 -0
- package/app/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
- package/app/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
- package/app/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
- package/app/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
- package/app/index.html +13 -0
- package/binding.gyp +69 -0
- package/csrc/arena.c +91 -0
- package/csrc/batch.c +229 -0
- package/csrc/buffer.c +78 -0
- package/csrc/cJSON.c +3143 -0
- package/csrc/cJSON.h +300 -0
- package/csrc/codec_csv.c +1058 -0
- package/csrc/codec_jsonl.c +374 -0
- package/csrc/codec_table.c +218 -0
- package/csrc/codec_text.c +229 -0
- package/csrc/compiler.c +102 -0
- package/csrc/date_utils.h +94 -0
- package/csrc/dsl.c +1180 -0
- package/csrc/dsl.h +22 -0
- package/csrc/expr.c +1245 -0
- package/csrc/expr.h +56 -0
- package/csrc/internal.h +250 -0
- package/csrc/ir.c +119 -0
- package/csrc/ir.h +167 -0
- package/csrc/ir_schema.c +60 -0
- package/csrc/ir_serialize.c +104 -0
- package/csrc/ir_sql.c +1211 -0
- package/csrc/ir_validate.c +120 -0
- package/csrc/main.c +392 -0
- package/csrc/op_acf.c +133 -0
- package/csrc/op_anomaly.c +120 -0
- package/csrc/op_bin.c +109 -0
- package/csrc/op_cast.c +195 -0
- package/csrc/op_clip.c +88 -0
- package/csrc/op_date_trunc.c +181 -0
- package/csrc/op_datetime.c +212 -0
- package/csrc/op_derive.c +248 -0
- package/csrc/op_diff.c +134 -0
- package/csrc/op_ewma.c +103 -0
- package/csrc/op_explode.c +108 -0
- package/csrc/op_fill_down.c +163 -0
- package/csrc/op_fill_null.c +123 -0
- package/csrc/op_filter.c +132 -0
- package/csrc/op_frequency.c +193 -0
- package/csrc/op_grep.c +163 -0
- package/csrc/op_group_agg.c +285 -0
- package/csrc/op_hash.c +126 -0
- package/csrc/op_head.c +149 -0
- package/csrc/op_interpolate.c +239 -0
- package/csrc/op_join.c +384 -0
- package/csrc/op_label_encode.c +144 -0
- package/csrc/op_lead.c +190 -0
- package/csrc/op_normalize.c +226 -0
- package/csrc/op_onehot.c +185 -0
- package/csrc/op_pivot.c +370 -0
- package/csrc/op_registry.c +1148 -0
- package/csrc/op_rename.c +138 -0
- package/csrc/op_replace.c +202 -0
- package/csrc/op_sample.c +101 -0
- package/csrc/op_select.c +140 -0
- package/csrc/op_skip.c +152 -0
- package/csrc/op_sort.c +273 -0
- package/csrc/op_split.c +114 -0
- package/csrc/op_split_data.c +87 -0
- package/csrc/op_stack.c +315 -0
- package/csrc/op_stats.c +779 -0
- package/csrc/op_step.c +171 -0
- package/csrc/op_tail.c +96 -0
- package/csrc/op_top.c +150 -0
- package/csrc/op_trim.c +109 -0
- package/csrc/op_unique.c +300 -0
- package/csrc/op_unpivot.c +159 -0
- package/csrc/op_validate.c +71 -0
- package/csrc/op_window.c +150 -0
- package/csrc/pipeline.c +315 -0
- package/csrc/plan.c +206 -0
- package/csrc/recipes.c +102 -0
- package/csrc/recipes.h +27 -0
- package/csrc/report.c +463 -0
- package/csrc/report.h +22 -0
- package/csrc/tranfi.h +123 -0
- package/csrc/wasm_api.c +157 -0
- package/napi_api.c +326 -0
- package/package.json +46 -57
- package/src/cli.js +193 -0
- package/src/engines/duckdb.js +109 -0
- package/src/index.js +306 -0
- package/src/native.js +22 -0
- package/src/pipeline.js +286 -0
- package/src/server.js +277 -0
- package/src/wasm.js +19 -0
- package/wasm/index.js +244 -0
- package/wasm/package.json +1 -0
- package/wasm/tranfi_core.js +0 -0
- package/LICENSE +0 -21
- package/dist/bundle.js +0 -1
- package/index.html +0 -18
- package/src/app.css +0 -169
- package/src/app.js +0 -203
- package/src/app.vue +0 -250
- package/src/bulma-input.vue +0 -110
- package/src/common-inputs.js +0 -28
- package/src/main.js +0 -20
- package/src/transforms.js +0 -166
- package/webpack.config.js +0 -108
package/src/pipeline.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|