node-riner 1.3.1 → 2.0.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.
Files changed (6) hide show
  1. package/README.md +72 -15
  2. package/index.ts +155 -53
  3. package/package.json +2 -2
  4. package/runs.js +0 -1
  5. package/t.rd +0 -0
  6. package/t.ts +0 -0
package/README.md CHANGED
@@ -1,28 +1,85 @@
1
- # Ride Framework
1
+ # Riner Framework (Node Riner)
2
2
 
3
- XMLファイルをベースに動的なHTMLページを生成し、Expressサーバーとして配信する軽量なSSR(Server-Side Rendering)ユーティリティです。
3
+ Rinerは独自のファイルをhtmlに変換し、Expressサーバーで配信します。
4
4
 
5
- ## 📦 インストール
6
-
7
- プロジェクトのルートディレクトリで以下のコマンドを実行してください。
5
+ ## インストール
8
6
 
9
7
  ```bash
10
8
  npm i node-riner@latest
11
9
  ```
12
- ## Example
13
- index.rd
14
- ``` RideFrameworks
10
+
11
+ ## 特長(主な機能)
12
+
13
+ - RinerRideFile(.rd) から HTML を自動生成して Express で配信
14
+ - `{ ... }` 内の JavaScript をサンドボックス実行して動的に値を埋め込み
15
+ - 永続状態管理 riner.state(サーバ側に保存)
16
+ - 永続的に実行される move(riner.move)を登録して state を更新
17
+ - riner.out / riner.move / riner.off / riner.clear をテンプレートから利用可能
18
+ - SSE(Server-Sent Events)で state の変更をクライアントに配信
19
+ - REST API で state / moves の取得・更新が可能
20
+
21
+ ## 使い方(最小構成)
22
+
23
+ index.rd(XML)
24
+ ```xml
15
25
  <Root>
16
- <Page route="/">
17
- <h1>hello world</h1>
18
- </Page>
26
+ <Page route="/">
27
+ <Content>
28
+ <h1>Hello</h1>
29
+ <p>{riner.out(new Date().toString())}</p>
30
+ </Content>
31
+ </Page>
19
32
  </Root>
20
33
  ```
34
+
21
35
  main.ts
22
- ```
36
+ ```ts
23
37
  import Riner from 'node-riner'
24
38
 
25
- let App = new Riner("my app")
26
- App.Ride("index.rd")
27
- App.serve(3000)
39
+ const app = new Riner('my app')
40
+ app.Ride('index.rd') // XML を読み込む
41
+ app.config({ siteName: 'MyBlog' }) // サーバ側既定コンテキスト
42
+ app.serve(3000) // サーバ起動
43
+ ```
44
+
45
+ ## riner API(テンプレート内で使用)
46
+
47
+ - riner.out(...args) — 出力を収集して文字列化して埋め込む
48
+ - riner.move(fn) — 永続的な move を登録(fn は関数。例: () => counter = (counter||0)+1)
49
+ - riner.off(fnOrSrc) — 指定した move を解除(関数かソース文字列で指定)
50
+ - riner.clear() — 全ての move を削除
51
+ - riner.state — サーバ保存の state オブジェクトへ参照
52
+
53
+ テンプレート例:
54
+ ```xml
55
+ <p>{riner.out(counter)}</p>
56
+ <p>{riner.move(() => counter = (counter || 0) + 1)}</p>
57
+ ```
58
+
59
+ ## サーバ側 API / SSE
60
+
61
+ - GET /__riner/state — 現在の riner.state を返す(JSON)
62
+ - POST /__riner/state — JSON を送って state をマージ
63
+ - GET /__riner/moves — 登録中の move 一覧を返す
64
+ - POST /__riner/moves — { source: "function source" } で move 登録
65
+ - DELETE /__riner/moves — { source: "function source" } で move 削除
66
+ - SSE /__riner/events — state 更新を受け取る(リアルタイム)
67
+
68
+ ## 注意(セキュリティ)
69
+
70
+ - テンプレート内の `{ ... }` はサンドボックス(vm)で実行しますが、任意コード実行のリスクは完全には無くなりません。
71
+ - 外部入力や未信頼のテンプレートを評価しないでください。プロダクションでは追加の制限・監査が必須です。
72
+
73
+ ## 例: Test.rd(サンプル)
74
+
75
+ ```xml
76
+ <Root>
77
+ <Page route="/">
78
+ <Content>
79
+ <h1>My Blog</h1>
80
+ <p>{riner.out(new Date().toString())}</p>
81
+ <p>訪問回数: {riner.out(counter)}</p>
82
+ </Content>
83
+ </Page>
84
+ </Root>
28
85
  ```
package/index.ts CHANGED
@@ -1,9 +1,9 @@
1
- // ...existing code...
2
1
  import fs, { readFileSync, writeFileSync } from 'fs'
3
2
  import { XMLParser } from 'fast-xml-parser'
4
3
  import exp from 'express'
5
4
  import path from 'path'
6
5
  import { execSync } from 'node:child_process'
6
+ import { runInNewContext } from 'vm'
7
7
 
8
8
  /**
9
9
  * Ride クラス
@@ -12,45 +12,137 @@ export default class Ride {
12
12
  F: string
13
13
  parsedJson: any
14
14
  pages: Record<string, string>
15
+ pagesTemplates: Record<string, any> // ルート => 生の Content オブジェクト(リクエスト毎に評価)
15
16
  name: string
16
17
  defaultinfo: string
18
+ defaultContext: any // サーバ/アプリ側で渡したい既定のコンテキスト
19
+
20
+ // riner 用の持続状態と登録済み move 関数のソース一覧
21
+ private rinerState: Record<string, any>
22
+ private rinerMoves: string[]
17
23
 
18
24
  constructor(name: string) {
19
25
  this.F = ""
20
26
  this.parsedJson = {}
21
27
  this.pages = {}
28
+ this.pagesTemplates = {}
22
29
  this.name = name
23
30
  this.defaultinfo = ""
31
+ this.defaultContext = {}
32
+ this.rinerState = {}
33
+ this.rinerMoves = []
34
+ }
35
+
36
+ /**
37
+ * 保存済み move 関数を sandbox 上で実行し、rinerState を同期する
38
+ * - ctx はそのまま sandbox に渡す(req 等を参照できるように)
39
+ * - move 関数内では riner.state または state のように直接変数を参照して変更可能
40
+ */
41
+ private runPersistedMoves(ctx: any = {}) {
42
+ if (!this.rinerMoves.length) return
43
+
44
+ // ベース sandbox:ctx をコピー
45
+ const sandboxBase: Record<string, any> = Object.assign({}, ctx)
46
+
47
+ // riner.state とトップレベル変数として現在の rinerState を注入
48
+ sandboxBase.riner = { state: this.rinerState }
49
+ Object.keys(this.rinerState).forEach(k => {
50
+ sandboxBase[k] = this.rinerState[k]
51
+ })
52
+
53
+ // 実行中にエラーが出ても次の move に進める
54
+ for (const src of this.rinerMoves) {
55
+ try {
56
+ // src は関数ソース文字列 (例: "() => cout += 1")
57
+ // 即時実行して state を更新させる
58
+ runInNewContext(`(${src})()`, sandboxBase, { timeout: 2000 })
59
+ } catch (e) {
60
+ // 失敗は無視(必要ならログを出す)
61
+ // console.error('move execution error', e)
62
+ }
63
+ }
64
+
65
+ // sandboxBase のトップレベル変数を rinerState に同期(riner/state は除外)
66
+ Object.keys(sandboxBase).forEach(k => {
67
+ if (k === 'riner' || k === 'console' || k === 'req' || k === 'server' || k === 'page' || k === 'data') return
68
+ this.rinerState[k] = sandboxBase[k]
69
+ })
24
70
  }
25
71
 
26
72
  /**
27
73
  * 値を再帰的に HTML に変換するユーティリティ
28
- * - 文字列: `{...}` を評価して置換(既存挙動を維持)
29
- * - 配列: 各要素を <item> でラップして連結
30
- * - オブジェクト: 各キーをタグ名として再帰的にレンダリング
31
- *
32
- * 注意: '@_' で始まるキー(属性)はここではタグ化しない。
74
+ * - ctx を渡すと { ... } 内のコードをそのコンテキストで評価する
75
+ * - riner.out / riner.move / riner.state を提供
33
76
  */
34
- private renderValue(value: any): string {
35
- // null / undefined
77
+ private renderValue(value: any, ctx: any = {}): string {
36
78
  if (value === null || value === undefined) return ''
37
79
 
38
80
  // 文字列処理({...} の評価を含む)
39
81
  if (typeof value === 'string') {
40
- // 複数マッチにも対応
41
82
  const matches = value.match(/{.*?}/g)
42
83
  if (matches) {
43
84
  let newValue = value
44
85
  matches.forEach(m => {
45
- const code = m.slice(1, -1) // 中身を取り出す
86
+ const code = m.slice(1, -1) // 中身
87
+
88
+ // まず、保存済み move を実行して最新の状態にする
89
+ this.runPersistedMoves(ctx)
90
+
91
+ // 出力収集用
92
+ const output: string[] = []
93
+
94
+ // sandbox を組み立て:ctx をコピー、riner、そしてトップレベルに rinerState を展開
95
+ const sandbox: Record<string, any> = Object.assign({}, ctx)
96
+
97
+ // riner オブジェクト(host 側のメソッドはクロスコンテキストで呼ばれる)
98
+ sandbox.riner = {
99
+ // 出力を収集するために riner.out を提供
100
+ out: (...args: any[]) => output.push(args.map(a => String(a)).join(' ')),
101
+ // riner.move は sandbox 内で関数を渡すと、その関数ソースを保存して以降持続的に実行される
102
+ move: (fn: Function) => {
103
+ try {
104
+ const src = fn.toString()
105
+ // 重複を避けるため同一ソースは再登録しない
106
+ if (!this.rinerMoves.includes(src)) this.rinerMoves.push(src)
107
+ return true
108
+ } catch (e) {
109
+ return false
110
+ }
111
+ },
112
+ // 直接 state にアクセスできるように参照を渡す
113
+ state: this.rinerState
114
+ }
115
+
116
+ // トップレベルでも直接 state の各キーを参照できるように注入
117
+ Object.keys(this.rinerState).forEach(k => {
118
+ sandbox[k] = this.rinerState[k]
119
+ })
120
+
121
+ // console を簡易的にラップして出力を収集
122
+ sandbox.console = {
123
+ log: (...args: any[]) => output.push(args.map(a => String(a)).join(' ')),
124
+ error: (...args: any[]) => output.push(args.map(a => String(a)).join(' ')),
125
+ warn: (...args: any[]) => output.push(args.map(a => String(a)).join(' '))
126
+ }
127
+
128
+ // 実行
129
+ console.log(sandbox)
130
+ console.log(code)
131
+ let res: any
46
132
  try {
47
- // 既存の実装を踏襲して runs.js に書き出して node で実行
48
- writeFileSync("./runs.js", code)
49
- const out = execSync("node runs.js").toString()
50
- newValue = newValue.replace(m, out)
133
+ res = runInNewContext(code, sandbox, { timeout: 2000 })
51
134
  } catch (e) {
52
- newValue = newValue.replace(m, '')
135
+ res = undefined
53
136
  }
137
+
138
+ // sandbox 上で変更されたトップレベル変数を rinerState に反映
139
+ Object.keys(sandbox).forEach(k => {
140
+ if (k === 'riner' || k === 'console' || k === 'req' || k === 'server' || k === 'page' || k === 'data') return
141
+ this.rinerState[k] = sandbox[k]
142
+ })
143
+
144
+ const out = output.length ? output.join('\n') : (res !== undefined ? String(res) : '')
145
+ newValue = newValue.replace(m, out)
54
146
  })
55
147
  return newValue
56
148
  }
@@ -59,74 +151,64 @@ export default class Ride {
59
151
 
60
152
  // 配列: 各要素を再帰的に処理して <item> でラップ
61
153
  if (Array.isArray(value)) {
62
- return value.map((item: any) => `<item>${this.renderValue(item)}</item>`).join('')
154
+ return value.map((item: any) => `<item>${this.renderValue(item, ctx)}</item>`).join('')
63
155
  }
64
156
 
65
157
  // オブジェクト: 属性(@_...) を無視して子要素だけタグ化する
66
158
  if (typeof value === 'object') {
67
159
  return Object.keys(value)
68
- .filter(key => !key.startsWith('@_')) // 属性はここで無視
160
+ .filter(key => !key.startsWith('@_'))
69
161
  .map(key => {
70
- // テキストノードは直接中身を出力
71
162
  if (key === '#text' || key === '_text') {
72
- return this.renderValue(value[key])
163
+ return this.renderValue(value[key], ctx)
73
164
  }
74
- return `<${key}>${this.renderValue(value[key])}</${key}>`
165
+ return `<${key}>${this.renderValue(value[key], ctx)}</${key}>`
75
166
  }).join('')
76
167
  }
77
168
 
78
- // その他(数値や真偽値など)はそのまま文字列化
79
169
  return String(value)
80
170
  }
81
171
 
82
172
  /**
83
173
  * タグ名とその値から、属性を正しく反映した HTML を生成する
84
- * - value が配列ならタグを複数分生成
85
- * - value がオブジェクトで属性(@_...)を持つ場合、属性を開タグに組み込む
174
+ * - ctx を渡して { ... } の中で req / server / page / data が参照できる
86
175
  */
87
- private renderContentTag(tag: string, value: any): string {
88
- // 配列の場合は各要素で同じタグを生成
176
+ private renderContentTag(tag: string, value: any, ctx: any = {}): string {
89
177
  if (Array.isArray(value)) {
90
- return value.map((item: any) => this.renderContentTag(tag, item)).join('')
178
+ return value.map((item: any) => this.renderContentTag(tag, item, ctx)).join('')
91
179
  }
92
180
 
93
- // プリミティブ(文字列等)の場合はタグでラップ
94
181
  if (value === null || value === undefined || typeof value !== 'object') {
95
- return `<${tag}>${this.renderValue(value)}</${tag}>`
182
+ return `<${tag}>${this.renderValue(value, ctx)}</${tag}>`
96
183
  }
97
184
 
98
- // オブジェクトの場合、属性を抽出
99
185
  const entries = Object.entries(value)
100
186
  const attrEntries = entries.filter(([k]) => k.startsWith('@_'))
101
187
  const childEntries = entries.filter(([k]) => !k.startsWith('@_'))
102
188
 
103
- // 属性文字列を組み立て(@_ を取り除く)
104
189
  const attrs = attrEntries.map(([k, v]) => {
105
190
  const name = k.replace(/^@_/, '')
106
191
  const safe = String(v).replace(/"/g, '&quot;')
107
192
  return ` ${name}="${safe}"`
108
193
  }).join('')
109
194
 
110
- // 子要素・テキストを組み立て
111
195
  let inner = ''
112
- // 優先してテキストノードを出す
113
196
  if (value['#text'] !== undefined) {
114
- inner += this.renderValue(value['#text'])
197
+ inner += this.renderValue(value['#text'], ctx)
115
198
  } else if (value['_text'] !== undefined) {
116
- inner += this.renderValue(value['_text'])
199
+ inner += this.renderValue(value['_text'], ctx)
117
200
  }
118
201
 
119
- // その他の子要素をレンダリング
120
202
  childEntries.forEach(([k, v]) => {
121
203
  if (k === '#text' || k === '_text') return
122
- inner += `<${k}>${this.renderValue(v)}</${k}>`
204
+ inner += `<${k}>${this.renderValue(v, ctx)}</${k}>`
123
205
  })
124
206
 
125
207
  return `<${tag}${attrs}>${inner}</${tag}>`
126
208
  }
127
209
 
128
210
  /**
129
- * XML を解析して pages HTML を格納
211
+ * XML を解析して pagesTemplates に生の Content を格納(評価はリクエスト時に行う)
130
212
  */
131
213
  returnhtml(): string {
132
214
  const options = { ignoreAttributes: false }
@@ -141,24 +223,14 @@ export default class Ride {
141
223
  const pages = root["Page"]
142
224
  if (!pages) return ""
143
225
 
144
- // Page が配列か単一かを吸収して配列化
145
226
  const pageArray = Array.isArray(pages) ? pages : [pages]
146
227
 
147
228
  pageArray.forEach((page: any) => {
148
229
  const route = page['@_route'] || '/'
149
-
150
- // Content が存在しない場合は空にする
151
230
  const content = page['Content'] || {}
152
231
 
153
- // 各タグを再帰的にレンダリングして結合
154
- const generatedhtml = Object.keys(content).map(tag => {
155
- const value = content[tag]
156
- // 属性付きタグなどを正しく扱う helper を使う
157
- return this.renderContentTag(tag, value)
158
- }).join('')
159
-
160
- // ページを格納(タイトルを付与)
161
- this.pages[route] = `<title>${this.name}</title>` + generatedhtml
232
+ // 生の content を保存しておく(ルートアクセス時に評価する)
233
+ this.pagesTemplates[route] = { content, pageMeta: page }
162
234
  })
163
235
  } catch (e) {
164
236
  console.error("Whoops, an error occurred during analysis : " + e)
@@ -171,19 +243,45 @@ export default class Ride {
171
243
  * Ride メソッド(ファイル読み込みと HTML 生成)
172
244
  */
173
245
  Ride(name: string) {
174
- this.F = readFileSync(path.join(__dirname, "../../", name), { encoding: "utf-8"})
246
+ // this.F = readFileSync(path.join(__dirname, "../../", name), { encoding: "utf-8"})
247
+ this.F = readFileSync(path.join(process.cwd(), name), { encoding: "utf-8"})
175
248
  this.returnhtml()
176
249
  }
177
250
 
178
251
  /**
179
252
  * 簡易サーバを起動
253
+ * - リクエスト毎に { ... } の評価を行い、req, query, params, body, server(defaultContext), page が参照可能になる
180
254
  */
181
255
  serve(port: number) {
182
256
  const app = exp();
183
257
 
184
- Object.keys(this.pages).forEach(route => {
258
+ Object.keys(this.pagesTemplates).forEach(route => {
185
259
  app.get(route, (req, res) => {
186
- res.send(this.pages[route])
260
+ const tpl = this.pagesTemplates[route]
261
+ const content = tpl?.content || {}
262
+
263
+ // コンテキストを組み立て(デフォルト + リクエスト情報 + ページメタ)
264
+ const ctx = {
265
+ server: this.defaultContext,
266
+ req: {
267
+ params: req.params,
268
+ query: req.query,
269
+ path: req.path,
270
+ headers: req.headers
271
+ },
272
+ page: tpl.pageMeta || {},
273
+ // 任意のデータを追加できるように空の data を用意
274
+ data: {}
275
+ }
276
+
277
+ // 各タグを評価して結合
278
+ const generatedhtml = Object.keys(content).map(tag => {
279
+ const value = content[tag]
280
+ return this.renderContentTag(tag, value, ctx)
281
+ }).join('')
282
+
283
+ // タイトルを付与して送信
284
+ res.send(`<title>${this.name}</title>` + generatedhtml)
187
285
  })
188
286
  })
189
287
 
@@ -194,7 +292,11 @@ export default class Ride {
194
292
  return app
195
293
  }
196
294
 
295
+ /**
296
+ * サーバ側で渡したい既定のコンテキストを設定する
297
+ * 例: ride.config({ currentUser: {...}, env: process.env.NODE_ENV })
298
+ */
197
299
  config(options: object) {
198
- // 将来の拡張ポイント
300
+ this.defaultContext = Object.assign({}, this.defaultContext, options)
199
301
  }
200
302
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "node-riner",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
7
- "dev": "tsx script.ts"
7
+ "dev": "tsx t.ts"
8
8
  },
9
9
  "keywords": [],
10
10
  "author": "",
package/runs.js DELETED
@@ -1 +0,0 @@
1
- // debug
package/t.rd DELETED
File without changes
package/t.ts DELETED
File without changes