odac 1.1.0 → 1.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/.agent/rules/coding.md +27 -0
- package/.agent/rules/memory.md +33 -0
- package/.agent/rules/project.md +30 -0
- package/.agent/rules/workflow.md +16 -0
- package/.github/workflows/release.yml +42 -1
- package/.github/workflows/test-coverage.yml +6 -5
- package/.github/workflows/test-publish.yml +36 -0
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +13 -0
- package/.releaserc.js +3 -3
- package/CHANGELOG.md +67 -0
- package/README.md +16 -0
- package/bin/odac.js +182 -40
- package/client/odac.js +10 -4
- package/docs/backend/01-overview/03-development-server.md +38 -45
- package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
- package/docs/backend/03-config/00-configuration-overview.md +6 -6
- package/docs/backend/03-config/01-database-connection.md +2 -2
- package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
- package/docs/backend/03-config/03-request-timeout.md +1 -1
- package/docs/backend/03-config/04-environment-variables.md +4 -4
- package/docs/backend/03-config/05-early-hints.md +2 -2
- package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
- package/docs/backend/04-routing/07-cron-jobs.md +17 -1
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
- package/docs/backend/05-controllers/03-controller-classes.md +40 -20
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
- package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
- package/docs/backend/08-database/01-getting-started.md +2 -2
- package/docs/backend/10-authentication/03-register.md +1 -1
- package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
- package/docs/backend/10-authentication/05-session-management.md +15 -1
- package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
- package/docs/backend/10-authentication/07-magic-links.md +1 -1
- package/docs/index.json +5 -1
- package/jest.config.js +1 -1
- package/package.json +9 -5
- package/src/Auth.js +58 -23
- package/src/Config.js +7 -7
- package/src/Env.js +3 -1
- package/src/Ipc.js +7 -0
- package/src/Lang.js +9 -2
- package/src/Odac.js +44 -35
- package/src/Request.js +1 -1
- package/src/Route/Cron.js +58 -17
- package/src/Route/Internal.js +1 -1
- package/src/Route.js +282 -99
- package/src/Server.js +40 -3
- package/src/Storage.js +4 -0
- package/src/Token.js +6 -4
- package/src/Validator.js +1 -1
- package/src/Var.js +22 -6
- package/src/View/EarlyHints.js +43 -33
- package/src/View/Form.js +17 -11
- package/src/View.js +62 -6
- package/template/package.json +3 -1
- package/template/view/content/home.html +3 -3
- package/template/view/head/main.html +2 -2
- package/test/Client.test.js +168 -0
- package/test/Config.test.js +112 -0
- package/test/Lang.test.js +92 -0
- package/test/Odac.test.js +86 -0
- package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
- package/test/{framework/Route.test.js → Route.test.js} +1 -1
- package/test/{framework/View → View}/EarlyHints.test.js +1 -1
- package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
- package/test/scripts/check-coverage.js +4 -4
- package/test/cli/Cli.test.js +0 -36
- package/test/core/Commands.test.js +0 -538
- package/test/core/Config.test.js +0 -1432
- package/test/core/Lang.test.js +0 -250
- package/test/core/Odac.test.js +0 -234
- package/test/core/Process.test.js +0 -156
- package/test/server/Api.test.js +0 -647
- package/test/server/DNS.test.js +0 -2050
- package/test/server/DNS.test.js.bak +0 -2084
- package/test/server/Hub.test.js +0 -497
- package/test/server/Log.test.js +0 -73
- package/test/server/Mail.account.test_.js +0 -460
- package/test/server/Mail.init.test_.js +0 -411
- package/test/server/Mail.test_.js +0 -1340
- package/test/server/SSL.test_.js +0 -1491
- package/test/server/Server.test.js +0 -765
- package/test/server/Service.test_.js +0 -1127
- package/test/server/Subdomain.test.js +0 -440
- package/test/server/Web/Firewall.test.js +0 -175
- package/test/server/Web/Proxy.test.js +0 -397
- package/test/server/Web.test.js +0 -1494
- package/test/server/__mocks__/acme-client.js +0 -17
- package/test/server/__mocks__/bcrypt.js +0 -50
- package/test/server/__mocks__/child_process.js +0 -389
- package/test/server/__mocks__/crypto.js +0 -432
- package/test/server/__mocks__/fs.js +0 -450
- package/test/server/__mocks__/globalOdac.js +0 -227
- package/test/server/__mocks__/http.js +0 -575
- package/test/server/__mocks__/https.js +0 -272
- package/test/server/__mocks__/index.js +0 -249
- package/test/server/__mocks__/mail/server.js +0 -100
- package/test/server/__mocks__/mail/smtp.js +0 -31
- package/test/server/__mocks__/mailparser.js +0 -81
- package/test/server/__mocks__/net.js +0 -369
- package/test/server/__mocks__/node-forge.js +0 -328
- package/test/server/__mocks__/os.js +0 -320
- package/test/server/__mocks__/path.js +0 -291
- package/test/server/__mocks__/selfsigned.js +0 -8
- package/test/server/__mocks__/server/src/mail/server.js +0 -100
- package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
- package/test/server/__mocks__/smtp-server.js +0 -106
- package/test/server/__mocks__/sqlite3.js +0 -394
- package/test/server/__mocks__/testFactories.js +0 -299
- package/test/server/__mocks__/testHelpers.js +0 -363
- package/test/server/__mocks__/tls.js +0 -229
- /package/template/{config.json → odac.json} +0 -0
package/src/Storage.js
CHANGED
|
@@ -22,6 +22,10 @@ class OdacStorage {
|
|
|
22
22
|
this.db = open({
|
|
23
23
|
path: dbPath,
|
|
24
24
|
compression: true
|
|
25
|
+
// CLUSTER SAFETY NOTE:
|
|
26
|
+
// LMDB uses memory-mapped files with OS-level locking logic.
|
|
27
|
+
// Multiple workers can safely read/write to this DB simultaneously.
|
|
28
|
+
// Data committed by Worker A is immediately visible to Worker B.
|
|
25
29
|
})
|
|
26
30
|
this.ready = true
|
|
27
31
|
} catch (error) {
|
package/src/Token.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
|
|
3
3
|
class Token {
|
|
4
|
+
// CLUSTER SAFETY NOTE:
|
|
5
|
+
// This is a request-scoped local cache (debounce) for performance.
|
|
6
|
+
// Valid tokens represent state persisted in Session (LMDB), shared across all workers.
|
|
4
7
|
confirmed = []
|
|
5
8
|
|
|
6
9
|
constructor(Request) {
|
|
@@ -22,10 +25,9 @@ class Token {
|
|
|
22
25
|
|
|
23
26
|
// - GENERATE TOKEN
|
|
24
27
|
generate() {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.digest('hex')
|
|
28
|
+
// Enterprise Standard: Use CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
|
|
29
|
+
// Replaced weak MD5(Math.random) with randomBytes(32)
|
|
30
|
+
let token = nodeCrypto.randomBytes(32).toString('hex')
|
|
29
31
|
let tokens = this.Request.session('_token') || []
|
|
30
32
|
tokens.push(token)
|
|
31
33
|
if (tokens.length > 50) tokens = tokens.slice(-50)
|
package/src/Validator.js
CHANGED
|
@@ -278,7 +278,7 @@ class Validator {
|
|
|
278
278
|
error = true
|
|
279
279
|
} else {
|
|
280
280
|
const userData = Odac.Auth.user(vars[1])
|
|
281
|
-
if (Odac.Var(userData).is('
|
|
281
|
+
if (Odac.Var(userData).is('hash')) {
|
|
282
282
|
error = !Odac.Var(userData).hashCheck(value)
|
|
283
283
|
} else {
|
|
284
284
|
error = value !== userData
|
package/src/Var.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const nodeCrypto = require('crypto')
|
|
3
|
-
const bcrypt = require('bcrypt')
|
|
4
3
|
|
|
5
4
|
class Var {
|
|
6
5
|
#value = null
|
|
@@ -79,17 +78,34 @@ class Var {
|
|
|
79
78
|
return encrypted.toString('base64')
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
hash(
|
|
83
|
-
|
|
81
|
+
hash() {
|
|
82
|
+
const salt = nodeCrypto.randomBytes(16).toString('hex')
|
|
83
|
+
const derivedKey = nodeCrypto.scryptSync(this.#value, salt, 64)
|
|
84
|
+
return `$scrypt$${salt}$${derivedKey.toString('hex')}`
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
hashCheck(check) {
|
|
87
|
-
|
|
88
|
+
if (!this.#value.startsWith('$scrypt$')) return false
|
|
89
|
+
const parts = this.#value.split('$')
|
|
90
|
+
if (parts.length < 4) return false
|
|
91
|
+
|
|
92
|
+
const salt = parts[2]
|
|
93
|
+
const originalHash = Buffer.from(parts[3], 'hex')
|
|
94
|
+
|
|
95
|
+
const derivedKey = nodeCrypto.scryptSync(check, salt, 64)
|
|
96
|
+
return nodeCrypto.timingSafeEqual(originalHash, derivedKey)
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
html() {
|
|
91
100
|
if (this.#value === null || this.#value === undefined) return ''
|
|
92
|
-
|
|
101
|
+
const map = {
|
|
102
|
+
'&': '&',
|
|
103
|
+
'<': '<',
|
|
104
|
+
'>': '>',
|
|
105
|
+
'"': '"',
|
|
106
|
+
"'": '''
|
|
107
|
+
}
|
|
108
|
+
return String(this.#value).replace(/[&<>"']/g, m => map[m])
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
is(...args) {
|
|
@@ -102,7 +118,7 @@ class Var {
|
|
|
102
118
|
if (args.includes('alphaspace')) result = (result || any) && ((any && result) || /^[A-Za-z\s]+$/.test(this.#value))
|
|
103
119
|
if (args.includes('alphanumeric')) result = (result || any) && ((any && result) || /^[A-Za-z0-9]+$/.test(this.#value))
|
|
104
120
|
if (args.includes('alphanumericspace')) result = (result || any) && ((any && result) || /^[A-Za-z0-9\s]+$/.test(this.#value))
|
|
105
|
-
if (args.includes('
|
|
121
|
+
if (args.includes('hash')) result = (result || any) && ((any && result) || /^\$scrypt\$[a-f0-9]+\$[a-f0-9]+$/.test(this.#value))
|
|
106
122
|
if (args.includes('date')) result = (result || any) && ((any && result) || !isNaN(Date.parse(this.#value)))
|
|
107
123
|
if (args.includes('domain')) result = (result || any) && ((any && result) || /^([a-z0-9-]+\.){1,2}[a-z]{2,6}$/i.test(this.#value))
|
|
108
124
|
if (args.includes('email'))
|
package/src/View/EarlyHints.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
1
|
+
const fs = require('fs').promises
|
|
2
2
|
const path = require('path')
|
|
3
3
|
|
|
4
4
|
class EarlyHints {
|
|
@@ -15,61 +15,71 @@ class EarlyHints {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
init() {
|
|
18
|
+
async init() {
|
|
19
19
|
if (this.#initialized) return
|
|
20
20
|
this.#initialized = true
|
|
21
21
|
|
|
22
22
|
if (!this.#config.enabled) return
|
|
23
23
|
|
|
24
|
-
this.#buildManifest()
|
|
24
|
+
await this.#buildManifest()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
#buildManifest() {
|
|
27
|
+
async #buildManifest() {
|
|
28
28
|
const viewDir = path.join(process.cwd(), 'view')
|
|
29
29
|
const skeletonDir = path.join(process.cwd(), 'skeleton')
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(viewDir)
|
|
34
|
+
const files = await this.#getAllViewFiles(viewDir)
|
|
35
|
+
await Promise.all(
|
|
36
|
+
files.map(async file => {
|
|
37
|
+
const html = await fs.readFile(file, 'utf8')
|
|
38
|
+
const resources = this.#extractResources(html)
|
|
39
|
+
|
|
40
|
+
const relativePath = path.relative(viewDir, file)
|
|
41
|
+
const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
|
|
42
|
+
|
|
43
|
+
if (resources.length > 0) {
|
|
44
|
+
this.#manifest[viewName] = resources
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
} catch {
|
|
49
|
+
// viewDir might not exist
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(skeletonDir)
|
|
54
|
+
const files = await this.#getAllViewFiles(skeletonDir)
|
|
55
|
+
await Promise.all(
|
|
56
|
+
files.map(async file => {
|
|
57
|
+
const html = await fs.readFile(file, 'utf8')
|
|
58
|
+
const resources = this.#extractResources(html)
|
|
59
|
+
|
|
60
|
+
const relativePath = path.relative(skeletonDir, file)
|
|
61
|
+
const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
|
|
62
|
+
|
|
63
|
+
if (resources.length > 0) {
|
|
64
|
+
this.#manifest[viewName] = resources
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
} catch {
|
|
69
|
+
// skeletonDir might not exist
|
|
60
70
|
}
|
|
61
71
|
} catch {
|
|
62
72
|
// Silently fail, manifest building is optional
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
#getAllViewFiles(dir, files = []) {
|
|
67
|
-
const entries = fs.
|
|
76
|
+
async #getAllViewFiles(dir, files = []) {
|
|
77
|
+
const entries = await fs.readdir(dir, {withFileTypes: true})
|
|
68
78
|
|
|
69
79
|
for (const entry of entries) {
|
|
70
80
|
const fullPath = path.join(dir, entry.name)
|
|
71
81
|
if (entry.isDirectory()) {
|
|
72
|
-
this.#getAllViewFiles(fullPath, files)
|
|
82
|
+
await this.#getAllViewFiles(fullPath, files)
|
|
73
83
|
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
74
84
|
files.push(fullPath)
|
|
75
85
|
}
|
package/src/View/Form.js
CHANGED
|
@@ -12,20 +12,26 @@ class Form {
|
|
|
12
12
|
|
|
13
13
|
static parseFormType(content, Odac, type) {
|
|
14
14
|
const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return content.replace(regex, match => {
|
|
16
|
+
const formConfig = this.extractConfig(match, null, type)
|
|
17
|
+
const configStr = JSON.stringify(formConfig)
|
|
18
|
+
const matchStr = JSON.stringify(match)
|
|
19
|
+
return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
|
|
20
|
+
})
|
|
21
|
+
}
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Generates the form at runtime to ensure a fresh token is created and stored
|
|
25
|
+
* in the current session for every request. This prevents "session expired"
|
|
26
|
+
* errors caused by caching the form token in the compiled view.
|
|
27
|
+
*/
|
|
28
|
+
static async runtime(Odac, type, config, originalHtml) {
|
|
29
|
+
const token = nodeCrypto.randomBytes(32).toString('hex')
|
|
30
|
+
config.token = token
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
this.storeConfig(token, config, Odac, type)
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
content = content.replace(match, generatedForm)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return content
|
|
34
|
+
return this.generateForm(originalHtml, config, token, type)
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
static extractConfig(html, formToken, type) {
|
package/src/View.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
const fs = require('fs')
|
|
3
|
+
const fsPromises = fs.promises
|
|
3
4
|
const Form = require('./View/Form')
|
|
4
5
|
const EarlyHints = require('./View/EarlyHints')
|
|
5
6
|
|
|
@@ -8,7 +9,6 @@ const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
|
|
|
8
9
|
const CACHE_DIR = './storage/.cache'
|
|
9
10
|
|
|
10
11
|
class View {
|
|
11
|
-
#cache = {}
|
|
12
12
|
#earlyHints = null
|
|
13
13
|
#functions = {
|
|
14
14
|
'{!!': {
|
|
@@ -116,6 +116,8 @@ class View {
|
|
|
116
116
|
} else {
|
|
117
117
|
this.#earlyHints = global.Odac.View.EarlyHints
|
|
118
118
|
}
|
|
119
|
+
global.Odac.View.Form = Form
|
|
120
|
+
this.Form = Form
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
all(name) {
|
|
@@ -205,8 +207,8 @@ class View {
|
|
|
205
207
|
|
|
206
208
|
// Normal page rendering
|
|
207
209
|
let result = ''
|
|
208
|
-
if (this.#part.skeleton &&
|
|
209
|
-
result =
|
|
210
|
+
if (this.#part.skeleton && (await this.#exists(`./skeleton/${this.#part.skeleton}.html`))) {
|
|
211
|
+
result = await this.#readSkeleton(`./skeleton/${this.#part.skeleton}.html`)
|
|
210
212
|
|
|
211
213
|
// Add data-odac-navigate to content wrapper for auto-navigation
|
|
212
214
|
result = this.#addNavigateAttribute(result)
|
|
@@ -363,10 +365,42 @@ class View {
|
|
|
363
365
|
}
|
|
364
366
|
|
|
365
367
|
async #render(file) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
+
if (!global.Odac.View) global.Odac.View = {}
|
|
369
|
+
if (!global.Odac.View.cache) global.Odac.View.cache = {}
|
|
370
|
+
|
|
371
|
+
// Performance: In Production, skip stat check if cached
|
|
372
|
+
if (!this.#odac.Config?.debug && global.Odac.View.cache[file]) {
|
|
373
|
+
try {
|
|
374
|
+
return await require(`${__dir}/${CACHE_DIR}/${global.Odac.View.cache[file].cache}`)(
|
|
375
|
+
this.#odac,
|
|
376
|
+
key => this.#odac.Request.get(key),
|
|
377
|
+
(...args) => this.#odac.Lang.get(...args)
|
|
378
|
+
)
|
|
379
|
+
} catch {
|
|
380
|
+
// Fallback if cache file missing
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let mtime = 0
|
|
385
|
+
let content = null
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const handle = await fsPromises.open(file, 'r')
|
|
389
|
+
try {
|
|
390
|
+
const stats = await handle.stat()
|
|
391
|
+
mtime = stats.mtimeMs
|
|
392
|
+
|
|
393
|
+
if (global.Odac.View.cache[file]?.mtime !== mtime) {
|
|
394
|
+
content = await handle.readFile('utf8')
|
|
395
|
+
}
|
|
396
|
+
} finally {
|
|
397
|
+
await handle.close()
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
return ''
|
|
401
|
+
}
|
|
368
402
|
|
|
369
|
-
if (
|
|
403
|
+
if (content !== null) {
|
|
370
404
|
content = Form.parse(content, this.#odac)
|
|
371
405
|
|
|
372
406
|
const jsBlocks = []
|
|
@@ -550,6 +584,28 @@ class View {
|
|
|
550
584
|
this.#odac.Request.setEarlyHints(hints)
|
|
551
585
|
}
|
|
552
586
|
}
|
|
587
|
+
|
|
588
|
+
async #exists(path) {
|
|
589
|
+
try {
|
|
590
|
+
await fsPromises.access(path)
|
|
591
|
+
return true
|
|
592
|
+
} catch {
|
|
593
|
+
return false
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async #readSkeleton(path) {
|
|
598
|
+
if (!global.Odac.View.skeletons) global.Odac.View.skeletons = {}
|
|
599
|
+
|
|
600
|
+
// In production (debug=false), cache logic
|
|
601
|
+
if (!this.#odac.Config?.debug && global.Odac.View.skeletons[path]) {
|
|
602
|
+
return global.Odac.View.skeletons[path]
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const content = await fsPromises.readFile(path, 'utf8')
|
|
606
|
+
global.Odac.View.skeletons[path] = content
|
|
607
|
+
return content
|
|
608
|
+
}
|
|
553
609
|
}
|
|
554
610
|
|
|
555
611
|
module.exports = View
|
package/template/package.json
CHANGED
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
|
|
61
61
|
<div class="structure-item">
|
|
62
62
|
<div class="structure-icon">⚙️</div>
|
|
63
|
-
<h3 class="structure-title">
|
|
63
|
+
<h3 class="structure-title">odac.json</h3>
|
|
64
64
|
<p class="structure-description">Configuration file for database, routes, and other settings.</p>
|
|
65
|
-
<code class="structure-path">
|
|
65
|
+
<code class="structure-path">odac.json</code>
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
68
|
</div>
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
<div class="feature-icon">🗄️</div>
|
|
157
157
|
<div class="feature-content">
|
|
158
158
|
<h3>Database Ready</h3>
|
|
159
|
-
<p>MySQL and SQLite support built-in. Configure in <code>
|
|
159
|
+
<p>MySQL and SQLite support built-in. Configure in <code>odac.json</code></p>
|
|
160
160
|
</div>
|
|
161
161
|
</div>
|
|
162
162
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<meta charset="UTF-8" />
|
|
2
2
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3
|
-
<title>
|
|
3
|
+
<title>ODAC</title>
|
|
4
4
|
<meta name="description" content="A next-generation server and framework toolkit for modern web development" />
|
|
5
|
-
<link rel="stylesheet" href="/assets/css/
|
|
5
|
+
<link rel="stylesheet" href="/assets/css/app.css" />
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
describe('Client (odac.js)', () => {
|
|
2
|
+
let mockXhr
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
jest.resetModules()
|
|
6
|
+
|
|
7
|
+
mockXhr = {
|
|
8
|
+
open: jest.fn(),
|
|
9
|
+
setRequestHeader: jest.fn(),
|
|
10
|
+
send: jest.fn(),
|
|
11
|
+
getResponseHeader: jest.fn(),
|
|
12
|
+
status: 200,
|
|
13
|
+
responseText: '{}',
|
|
14
|
+
response: '{}',
|
|
15
|
+
onload: null,
|
|
16
|
+
onerror: null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockDocument = {
|
|
20
|
+
getElementById: jest.fn(),
|
|
21
|
+
querySelectorAll: jest.fn(() => []),
|
|
22
|
+
querySelector: jest.fn(),
|
|
23
|
+
addEventListener: jest.fn(),
|
|
24
|
+
removeEventListener: jest.fn(),
|
|
25
|
+
dispatchEvent: jest.fn(),
|
|
26
|
+
documentElement: {dataset: {}},
|
|
27
|
+
cookie: '',
|
|
28
|
+
readyState: 'complete',
|
|
29
|
+
createElement: jest.fn(() => ({
|
|
30
|
+
setAttribute: jest.fn(),
|
|
31
|
+
style: {},
|
|
32
|
+
appendChild: jest.fn(),
|
|
33
|
+
parentNode: {insertBefore: jest.fn()}
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mockWindow = {
|
|
38
|
+
location: {
|
|
39
|
+
protocol: 'http:',
|
|
40
|
+
host: 'localhost',
|
|
41
|
+
href: 'http://localhost/'
|
|
42
|
+
},
|
|
43
|
+
history: {
|
|
44
|
+
pushState: jest.fn()
|
|
45
|
+
},
|
|
46
|
+
scrollTo: jest.fn(),
|
|
47
|
+
addEventListener: jest.fn(),
|
|
48
|
+
XMLHttpRequest: jest.fn(() => mockXhr),
|
|
49
|
+
localStorage: {
|
|
50
|
+
getItem: jest.fn(),
|
|
51
|
+
setItem: jest.fn(),
|
|
52
|
+
removeItem: jest.fn()
|
|
53
|
+
},
|
|
54
|
+
CustomEvent: jest.fn((name, detail) => ({name, detail})),
|
|
55
|
+
setTimeout: jest.fn(),
|
|
56
|
+
clearTimeout: jest.fn(),
|
|
57
|
+
requestAnimationFrame: jest.fn(cb => cb(Date.now())),
|
|
58
|
+
WebSocket: jest.fn(() => ({
|
|
59
|
+
send: jest.fn(),
|
|
60
|
+
close: jest.fn(),
|
|
61
|
+
readyState: 1 // OPEN
|
|
62
|
+
})),
|
|
63
|
+
FormData: jest.fn()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mockWindow.window = mockWindow
|
|
67
|
+
mockWindow.document = mockDocument
|
|
68
|
+
mockWindow.WebSocket.OPEN = 1
|
|
69
|
+
mockWindow.WebSocket.CLOSED = 3
|
|
70
|
+
|
|
71
|
+
global.window = mockWindow
|
|
72
|
+
global.document = mockDocument
|
|
73
|
+
global.location = mockWindow.location
|
|
74
|
+
global.XMLHttpRequest = mockWindow.XMLHttpRequest
|
|
75
|
+
global.localStorage = mockWindow.localStorage
|
|
76
|
+
global.CustomEvent = mockWindow.CustomEvent
|
|
77
|
+
global.WebSocket = mockWindow.WebSocket
|
|
78
|
+
global.setTimeout = mockWindow.setTimeout
|
|
79
|
+
global.clearTimeout = mockWindow.clearTimeout
|
|
80
|
+
global.requestAnimationFrame = mockWindow.requestAnimationFrame
|
|
81
|
+
global.FormData = mockWindow.FormData
|
|
82
|
+
|
|
83
|
+
delete require.cache[require.resolve('../client/odac.js')]
|
|
84
|
+
require('../client/odac.js')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
delete global.window
|
|
89
|
+
delete global.document
|
|
90
|
+
delete global.location
|
|
91
|
+
delete global.XMLHttpRequest
|
|
92
|
+
delete global.localStorage
|
|
93
|
+
delete global.CustomEvent
|
|
94
|
+
delete global.WebSocket
|
|
95
|
+
delete global.setTimeout
|
|
96
|
+
delete global.clearTimeout
|
|
97
|
+
delete global.requestAnimationFrame
|
|
98
|
+
delete global.FormData
|
|
99
|
+
delete global.Odac
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('Odac should be initialized on window', () => {
|
|
103
|
+
expect(window.Odac).toBeDefined()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('data()', () => {
|
|
107
|
+
test('should retrieve data from odac-data script tag', () => {
|
|
108
|
+
const mockData = {user: 'emre'}
|
|
109
|
+
document.getElementById.mockReturnValue({
|
|
110
|
+
textContent: JSON.stringify(mockData)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const result = window.Odac.data()
|
|
114
|
+
expect(result).toEqual(mockData)
|
|
115
|
+
expect(document.getElementById).toHaveBeenCalledWith('odac-data')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('should return specific key from data', () => {
|
|
119
|
+
const mockData = {user: 'emre', role: 'admin'}
|
|
120
|
+
document.getElementById.mockReturnValue({
|
|
121
|
+
textContent: JSON.stringify(mockData)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(window.Odac.data('user')).toBe('emre')
|
|
125
|
+
expect(window.Odac.data('role')).toBe('admin')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('storage()', () => {
|
|
130
|
+
test('should get item from localStorage', () => {
|
|
131
|
+
localStorage.getItem.mockReturnValue('val')
|
|
132
|
+
expect(window.Odac.storage('key')).toBe('val')
|
|
133
|
+
expect(localStorage.getItem).toHaveBeenCalledWith('key')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('should set item in localStorage', () => {
|
|
137
|
+
window.Odac.storage('key', 'val')
|
|
138
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('key', 'val')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('token()', () => {
|
|
143
|
+
test('should fetch token via sync XHR if hash is empty', () => {
|
|
144
|
+
mockXhr.response = JSON.stringify({token: 'new-token'})
|
|
145
|
+
document.cookie = 'odac_client=abc'
|
|
146
|
+
|
|
147
|
+
const token = window.Odac.token()
|
|
148
|
+
|
|
149
|
+
expect(window.XMLHttpRequest).toHaveBeenCalled()
|
|
150
|
+
expect(token).toBe('new-token')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('OdacWebSocket', () => {
|
|
155
|
+
test('should connect to WebSocket and handle events', () => {
|
|
156
|
+
const ws = window.Odac.ws('/test-ws', {token: false})
|
|
157
|
+
expect(window.WebSocket).toHaveBeenCalled()
|
|
158
|
+
|
|
159
|
+
const openHandler = jest.fn()
|
|
160
|
+
ws.on('open', openHandler)
|
|
161
|
+
|
|
162
|
+
const socketInstance = WebSocket.mock.results[0].value
|
|
163
|
+
socketInstance.onopen()
|
|
164
|
+
|
|
165
|
+
expect(openHandler).toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const os = require('os')
|
|
3
|
+
|
|
4
|
+
const Config = require('../src/Config')
|
|
5
|
+
|
|
6
|
+
jest.mock('fs')
|
|
7
|
+
jest.mock('os')
|
|
8
|
+
|
|
9
|
+
describe('Config', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.clearAllMocks()
|
|
12
|
+
// Reset global.__dir which is used in Config.js
|
|
13
|
+
global.__dir = '/mock/project'
|
|
14
|
+
|
|
15
|
+
// Reset Config properties to defaults before each test
|
|
16
|
+
Config.system = undefined
|
|
17
|
+
Config.encrypt.key = 'odac'
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('init', () => {
|
|
21
|
+
it('should load system config from home directory', () => {
|
|
22
|
+
os.homedir.mockReturnValue('/home/user')
|
|
23
|
+
fs.readFileSync.mockImplementation(path => {
|
|
24
|
+
if (path === '/home/user/.odac/config.json') {
|
|
25
|
+
return JSON.stringify({deviceId: '123'})
|
|
26
|
+
}
|
|
27
|
+
return '{}'
|
|
28
|
+
})
|
|
29
|
+
fs.existsSync.mockReturnValue(false)
|
|
30
|
+
|
|
31
|
+
Config.init()
|
|
32
|
+
|
|
33
|
+
expect(Config.system).toEqual({deviceId: '123'})
|
|
34
|
+
expect(fs.readFileSync).toHaveBeenCalledWith('/home/user/.odac/config.json')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should load project config and merge it', () => {
|
|
38
|
+
os.homedir.mockReturnValue('/home/user')
|
|
39
|
+
fs.existsSync.mockImplementation(path => {
|
|
40
|
+
if (path === '/mock/project/odac.json') return true
|
|
41
|
+
return false
|
|
42
|
+
})
|
|
43
|
+
fs.readFileSync.mockImplementation(path => {
|
|
44
|
+
if (path === '/mock/project/odac.json') {
|
|
45
|
+
return JSON.stringify({encrypt: {key: 'secret'}})
|
|
46
|
+
}
|
|
47
|
+
return '{}'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
Config.init()
|
|
51
|
+
|
|
52
|
+
// The key gets hashed in init(), so it won't be 'secret' anymore
|
|
53
|
+
expect(Config.encrypt.key).not.toBe('secret')
|
|
54
|
+
expect(Config.encrypt.key).toBeInstanceOf(Buffer)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should interpolate variables in config', () => {
|
|
58
|
+
process.env.TEST_VAR = 'env_value'
|
|
59
|
+
os.homedir.mockReturnValue('/home/user')
|
|
60
|
+
fs.existsSync.mockReturnValue(true)
|
|
61
|
+
fs.readFileSync.mockReturnValue(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
custom: 'value-${TEST_VAR}'
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
Config.init()
|
|
68
|
+
|
|
69
|
+
expect(Config.custom).toBe('value-env_value')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('_interpolate', () => {
|
|
74
|
+
it('should replace ${VAR} with environment variables', () => {
|
|
75
|
+
process.env.FOO = 'bar'
|
|
76
|
+
const result = Config._interpolate('hello-${FOO}')
|
|
77
|
+
expect(result).toBe('hello-bar')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should replace ${odac} with client path', () => {
|
|
81
|
+
// __dirname in Config.js is /.../src, so it replaces /src with /client
|
|
82
|
+
const result = Config._interpolate('path-${odac}')
|
|
83
|
+
expect(result).toMatch(/\/client$/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle nested objects and arrays', () => {
|
|
87
|
+
process.env.VAR = 'x'
|
|
88
|
+
const obj = {
|
|
89
|
+
a: ['${VAR}'],
|
|
90
|
+
b: {c: '${VAR}'}
|
|
91
|
+
}
|
|
92
|
+
const result = Config._interpolate(obj)
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
a: ['x'],
|
|
95
|
+
b: {c: 'x'}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('_deepMerge', () => {
|
|
101
|
+
it('should merge objects deeply', () => {
|
|
102
|
+
const target = {a: {b: 1}, c: 2}
|
|
103
|
+
const source = {a: {d: 3}, e: 4}
|
|
104
|
+
Config._deepMerge(target, source)
|
|
105
|
+
expect(target).toEqual({
|
|
106
|
+
a: {b: 1, d: 3},
|
|
107
|
+
c: 2,
|
|
108
|
+
e: 4
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|