tranfi 0.0.2 → 0.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/LICENSE +177 -21
- package/NOTICE +8 -0
- package/README.md +627 -0
- package/app/assets/index-6quYZ5Ap.css +5 -0
- package/app/assets/index-BIAIKnrp.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 +121 -0
- package/csrc/arena.c +93 -0
- package/csrc/batch.c +976 -0
- package/csrc/buffer.c +154 -0
- package/csrc/cJSON.c +3386 -0
- package/csrc/cJSON.h +316 -0
- package/csrc/codec_csv.c +1951 -0
- package/csrc/codec_jsonl.c +1086 -0
- package/csrc/codec_table.c +248 -0
- package/csrc/codec_text.c +447 -0
- package/csrc/compiler.c +130 -0
- package/csrc/config.h +21 -0
- package/csrc/date_utils.h +94 -0
- package/csrc/dsl.c +5417 -0
- package/csrc/dsl.h +22 -0
- package/csrc/expr.c +1553 -0
- package/csrc/expr.h +58 -0
- package/csrc/internal.h +539 -0
- package/csrc/ir.c +166 -0
- package/csrc/ir.h +208 -0
- package/csrc/ir_schema.c +75 -0
- package/csrc/ir_serialize.c +166 -0
- package/csrc/ir_sql.c +1822 -0
- package/csrc/ir_validate.c +576 -0
- package/csrc/json_path.c +210 -0
- package/csrc/main.c +1241 -0
- package/csrc/memory_estimate.c +477 -0
- package/csrc/op_acf.c +283 -0
- package/csrc/op_across.c +477 -0
- package/csrc/op_anomaly.c +255 -0
- package/csrc/op_assert.c +761 -0
- package/csrc/op_bin.c +248 -0
- package/csrc/op_cast.c +523 -0
- package/csrc/op_clip.c +99 -0
- package/csrc/op_date_trunc.c +355 -0
- package/csrc/op_datetime.c +394 -0
- package/csrc/op_derive.c +216 -0
- package/csrc/op_diff.c +250 -0
- package/csrc/op_ewma.c +222 -0
- package/csrc/op_explode.c +206 -0
- package/csrc/op_fill_down.c +235 -0
- package/csrc/op_fill_null.c +268 -0
- package/csrc/op_filter.c +181 -0
- package/csrc/op_frequency.c +721 -0
- package/csrc/op_grep.c +181 -0
- package/csrc/op_group_agg.c +1956 -0
- package/csrc/op_hash.c +159 -0
- package/csrc/op_head.c +84 -0
- package/csrc/op_interpolate.c +445 -0
- package/csrc/op_join.c +2902 -0
- package/csrc/op_json_extract.c +227 -0
- package/csrc/op_json_filter.c +384 -0
- package/csrc/op_json_flatten.c +293 -0
- package/csrc/op_json_schema.c +503 -0
- package/csrc/op_label_encode.c +419 -0
- package/csrc/op_lag.c +181 -0
- package/csrc/op_lead.c +242 -0
- package/csrc/op_normalize.c +510 -0
- package/csrc/op_onehot.c +457 -0
- package/csrc/op_pivot.c +1754 -0
- package/csrc/op_quarantine.c +189 -0
- package/csrc/op_registry.c +3044 -0
- package/csrc/op_rename.c +129 -0
- package/csrc/op_replace.c +354 -0
- package/csrc/op_rleid.c +297 -0
- package/csrc/op_rowid.c +559 -0
- package/csrc/op_sample.c +158 -0
- package/csrc/op_schema.c +1341 -0
- package/csrc/op_schema_infer.c +252 -0
- package/csrc/op_select.c +340 -0
- package/csrc/op_set.c +3449 -0
- package/csrc/op_skip.c +95 -0
- package/csrc/op_sort.c +819 -0
- package/csrc/op_source_name.c +120 -0
- package/csrc/op_split.c +151 -0
- package/csrc/op_split_data.c +119 -0
- package/csrc/op_stack.c +271 -0
- package/csrc/op_stats.c +875 -0
- package/csrc/op_step.c +333 -0
- package/csrc/op_tail.c +105 -0
- package/csrc/op_tee.c +338 -0
- package/csrc/op_top.c +357 -0
- package/csrc/op_trim.c +138 -0
- package/csrc/op_unique.c +1343 -0
- package/csrc/op_unpivot.c +193 -0
- package/csrc/op_validate.c +648 -0
- package/csrc/op_window.c +591 -0
- package/csrc/path_policy.c +85 -0
- package/csrc/pipeline.c +1088 -0
- package/csrc/recipes.c +104 -0
- package/csrc/recipes.h +27 -0
- package/csrc/report.c +506 -0
- package/csrc/report.h +22 -0
- package/csrc/selector.c +1097 -0
- package/csrc/size_utils.c +348 -0
- package/csrc/spill.c +317 -0
- package/csrc/spill.h +21 -0
- package/csrc/tranfi.h +291 -0
- package/csrc/transform.h +209 -0
- package/csrc/transform_api.c +2237 -0
- package/csrc/transform_categorical.c +923 -0
- package/csrc/transform_internal.h +472 -0
- package/csrc/transform_json.c +3812 -0
- package/csrc/transform_numeric.c +1966 -0
- package/csrc/transform_sha256.c +154 -0
- package/csrc/transform_wasm.h +162 -0
- package/csrc/transform_wasm_api.c +1373 -0
- package/csrc/wasm_api.c +218 -0
- package/napi_api.c +534 -0
- package/napi_transform.c +1648 -0
- package/napi_transform.h +8 -0
- package/package.json +64 -59
- package/scripts/install-native.js +76 -0
- package/scripts/prepack.js +64 -0
- package/scripts/sync-csrc.js +23 -0
- package/src/cli.js +190 -0
- package/src/engines/duckdb.js +142 -0
- package/src/index.js +925 -0
- package/src/memory_policy.js +411 -0
- package/src/native.js +18 -0
- package/src/pipeline.js +709 -0
- package/src/recipe_json.js +80 -0
- package/src/server.js +279 -0
- package/src/transform.js +403 -0
- package/src/transform_error.js +10 -0
- package/src/wasm.js +21 -0
- package/wasm/index.js +732 -0
- package/wasm/package.json +1 -0
- package/wasm/tranfi_core.js +0 -0
- package/wasm/transform.js +1156 -0
- package/wasm/worker.js +786 -0
- 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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { TranfiTransformError } = require('./transform_error.js')
|
|
4
|
+
|
|
5
|
+
// The replacer runs before JSON.stringify emits each value. Charge the exact
|
|
6
|
+
// UTF-8 wire size there, so neither a giant string nor many small fields can
|
|
7
|
+
// allocate an over-budget JSON result. Getters/toJSON are evaluated only once.
|
|
8
|
+
function stringifyRecipe(recipe, limits) {
|
|
9
|
+
const limit = Math.min(limits.maxRecipeBytes, limits.maxAllocationBytes - 1)
|
|
10
|
+
let bytes = 0
|
|
11
|
+
const parents = []
|
|
12
|
+
const counts = []
|
|
13
|
+
function charge(n) {
|
|
14
|
+
bytes += n
|
|
15
|
+
if (bytes > limit) {
|
|
16
|
+
throw new TranfiTransformError(104, 'recipe exceeds its configured byte limit')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function string(value) {
|
|
20
|
+
charge(2)
|
|
21
|
+
for (let i = 0; i < value.length; i++) {
|
|
22
|
+
const c = value.charCodeAt(i)
|
|
23
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
24
|
+
const next = value.charCodeAt(++i)
|
|
25
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
|
|
26
|
+
throw new TypeError('recipe must not contain an unpaired UTF-16 surrogate')
|
|
27
|
+
}
|
|
28
|
+
charge(4)
|
|
29
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
30
|
+
throw new TypeError('recipe must not contain an unpaired UTF-16 surrogate')
|
|
31
|
+
} else if (c === 34 || c === 92 || (c >= 8 && c <= 10) || c === 12 || c === 13) charge(2)
|
|
32
|
+
else if (c < 32) charge(6)
|
|
33
|
+
else charge(c < 128 ? 1 : c < 2048 ? 2 : 3)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(recipe, function(key, value) {
|
|
37
|
+
while (parents.length && parents[parents.length - 1] !== this) {
|
|
38
|
+
parents.pop()
|
|
39
|
+
counts.pop()
|
|
40
|
+
}
|
|
41
|
+
// JSON's boxed primitives are scalars. Use its conversion semantics before
|
|
42
|
+
// counting; returning the scalar prevents a second custom coercion.
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
let boxed = false
|
|
45
|
+
try { Number.prototype.valueOf.call(value); boxed = true } catch (_) {}
|
|
46
|
+
if (boxed) value = Number(value)
|
|
47
|
+
else {
|
|
48
|
+
try { String.prototype.valueOf.call(value); boxed = true } catch (_) {}
|
|
49
|
+
if (boxed) value = String(value)
|
|
50
|
+
else {
|
|
51
|
+
try { value = Boolean.prototype.valueOf.call(value) } catch (_) {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const type = typeof value
|
|
56
|
+
const omitted = value === undefined || type === 'function' || type === 'symbol'
|
|
57
|
+
if (omitted && !Array.isArray(this)) return undefined
|
|
58
|
+
if (parents.length) {
|
|
59
|
+
const last = parents.length - 1
|
|
60
|
+
if (counts[last]++) charge(1)
|
|
61
|
+
if (!Array.isArray(this)) { string(key); charge(1) }
|
|
62
|
+
}
|
|
63
|
+
if (value === null || omitted) charge(4)
|
|
64
|
+
else if (type === 'string') string(value)
|
|
65
|
+
else if (type === 'number') charge(Number.isFinite(value) ? String(value).length : 4)
|
|
66
|
+
else if (type === 'boolean') charge(value ? 4 : 5)
|
|
67
|
+
else if (type === 'object') {
|
|
68
|
+
if (parents.includes(value)) throw new TypeError('circular recipe')
|
|
69
|
+
if (parents.length + 1 > limits.maxJsonDepth) {
|
|
70
|
+
throw new TranfiTransformError(104, 'recipe exceeds its configured depth limit')
|
|
71
|
+
}
|
|
72
|
+
charge(2)
|
|
73
|
+
parents.push(value)
|
|
74
|
+
counts.push(0)
|
|
75
|
+
}
|
|
76
|
+
return value
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { stringifyRecipe }
|
package/src/server.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
const { createServer } = require('http')
|
|
10
|
+
const { readFileSync, readdirSync, statSync, readFile: readFileCb, existsSync } = require('fs')
|
|
11
|
+
const { join, extname, resolve, basename } = require('path')
|
|
12
|
+
const { URL } = require('url')
|
|
13
|
+
const { pipeline, recipes } = require('./index.js')
|
|
14
|
+
const nativeBinding = require('./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
|
+
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, hostedAuditDefaults: true })
|
|
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
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { startServer }
|