odac 1.4.13 → 1.4.15
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/CHANGELOG.md +26 -0
- package/bin/odac.js +1 -0
- package/client/odac.js +19 -0
- package/docs/ai/skills/frontend/core.md +5 -5
- package/docs/ai/skills/frontend/scripts.md +74 -18
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
- package/docs/backend/05-forms/01-custom-forms.md +44 -0
- package/docs/frontend/01-overview/01-introduction.md +8 -8
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
- package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
- package/docs/frontend/03-forms/01-form-handling.md +55 -19
- package/docs/frontend/04-api-requests/01-get-post.md +25 -25
- package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
- package/docs/frontend/06-websocket/00-overview.md +1 -1
- package/index.js +11 -1
- package/package.json +1 -1
- package/src/Request.js +12 -6
- package/src/Route.js +7 -0
- package/src/View/Form.js +35 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
### 🛠️ Fixes & Improvements
|
|
2
2
|
|
|
3
|
+
- **docs:** update references from 'odac' to 'Odac' for consistency
|
|
4
|
+
- **form:** improve form submission handler ownership to prevent duplicate submissions
|
|
5
|
+
- **form:** prevent multiple submit handlers on re-bound forms
|
|
6
|
+
- **startup:** surface init/migration errors instead of swallowing them
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
13
|
+
|
|
14
|
+
### 🛠️ Fixes & Improvements
|
|
15
|
+
|
|
16
|
+
- **docs:** update frontend scripts documentation to include code obfuscation details and enhance clarity
|
|
17
|
+
- **form:** add escapeHtmlPreservingTemplates method to handle template tokens
|
|
18
|
+
- **route:** enhance error handling to support object responses in error method
|
|
19
|
+
- **view:** use escapeHtmlPreservingTemplates consistently for form attributes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
26
|
+
|
|
27
|
+
### 🛠️ Fixes & Improvements
|
|
28
|
+
|
|
3
29
|
- **form:** enhance form handling with metadata and improved parsing logic
|
|
4
30
|
- **form:** implement token rotation on successful form submissions
|
|
5
31
|
- **view:** prevent script injection and handle escaped quotes in form configs
|
package/bin/odac.js
CHANGED
|
@@ -532,6 +532,7 @@ async function runMigration(cmd, cliArgs) {
|
|
|
532
532
|
}
|
|
533
533
|
} catch (err) {
|
|
534
534
|
console.error('❌ Migration error:', err.message)
|
|
535
|
+
if (err.cause) console.error(' Cause:', err.cause.message || err.cause)
|
|
535
536
|
process.exit(1)
|
|
536
537
|
} finally {
|
|
537
538
|
for (const conn of Object.values(connections)) {
|
package/client/odac.js
CHANGED
|
@@ -119,6 +119,7 @@ if (typeof window !== 'undefined') {
|
|
|
119
119
|
#page = null
|
|
120
120
|
#token = {hash: [], data: false}
|
|
121
121
|
#formSubmitHandlers = new Map()
|
|
122
|
+
#formElementSelectors = new WeakMap()
|
|
122
123
|
#loader = {elements: {}, callback: null, parts: {}}
|
|
123
124
|
#isNavigating = false
|
|
124
125
|
|
|
@@ -412,6 +413,20 @@ if (typeof window !== 'undefined') {
|
|
|
412
413
|
const formElement = e.target.closest(formSelector)
|
|
413
414
|
if (!formElement) return
|
|
414
415
|
|
|
416
|
+
// Ownership guard: a single form can be matched by more than one
|
|
417
|
+
// registered selector — e.g. an <odac:form> auto-bound by #initForms
|
|
418
|
+
// under its data-odac-form token, then re-bound by the app via
|
|
419
|
+
// Odac.form('#id', cb) to attach a callback. The form is tagged with the
|
|
420
|
+
// selector it was last registered under; only that handler may process
|
|
421
|
+
// the submit, so the form fires exactly once instead of 2x. We don't
|
|
422
|
+
// tear down the other selector's listener (it may still serve *other*
|
|
423
|
+
// forms — e.g. a shared '.ajax-form' selector), we just let those
|
|
424
|
+
// handlers ignore this form. Forms never explicitly (re-)bound aren't
|
|
425
|
+
// tracked and fall through normally, preserving delegation for forms
|
|
426
|
+
// added to the DOM after registration.
|
|
427
|
+
const owner = this.#formElementSelectors.get(formElement)
|
|
428
|
+
if (owner && owner !== formSelector) return
|
|
429
|
+
|
|
415
430
|
e.preventDefault()
|
|
416
431
|
|
|
417
432
|
if (obj.messages !== false) {
|
|
@@ -680,6 +695,7 @@ if (typeof window !== 'undefined') {
|
|
|
680
695
|
|
|
681
696
|
document.addEventListener('submit', handler)
|
|
682
697
|
this.#formSubmitHandlers.set(formSelector, handler)
|
|
698
|
+
document.querySelectorAll(formSelector).forEach(formEl => this.#formElementSelectors.set(formEl, formSelector))
|
|
683
699
|
}
|
|
684
700
|
|
|
685
701
|
get(url, callback) {
|
|
@@ -997,6 +1013,9 @@ if (typeof window !== 'undefined') {
|
|
|
997
1013
|
|
|
998
1014
|
for (const {cls, attr} of formTypes) {
|
|
999
1015
|
document.querySelectorAll(`form.${cls}[${attr}]`).forEach(form => {
|
|
1016
|
+
// Skip forms the app already bound via Odac.form(...) — re-registering
|
|
1017
|
+
// here would attach a second handler and cause 2x submit.
|
|
1018
|
+
if (this.#formElementSelectors.has(form)) return
|
|
1000
1019
|
const token = form.getAttribute(attr)
|
|
1001
1020
|
const selector = `form[${attr}="${token}"]`
|
|
1002
1021
|
if (!this.#formSubmitHandlers.has(selector)) {
|
|
@@ -18,7 +18,7 @@ Frontend logic is organized into **Actions** using `Odac.action()`. This method
|
|
|
18
18
|
- `start`: Fires once when the script initializes.
|
|
19
19
|
- `load`: Fires after every page load (including AJAX navigations).
|
|
20
20
|
3. **Page Scoping**: Use the `page: { name: fn }` object to isolate code to specific routes.
|
|
21
|
-
4. **Persistent Storage**: Use `
|
|
21
|
+
4. **Persistent Storage**: Use `Odac.storage(key, value)` for a secure LocalStorage wrapper.
|
|
22
22
|
|
|
23
23
|
## Reference Patterns
|
|
24
24
|
|
|
@@ -60,11 +60,11 @@ Odac.action({
|
|
|
60
60
|
### 3. Data Utilities
|
|
61
61
|
```javascript
|
|
62
62
|
// Accessing data shared from backend (Odac.share)
|
|
63
|
-
const user =
|
|
63
|
+
const user = Odac.data('user')
|
|
64
64
|
|
|
65
65
|
// Using Storage wrapper
|
|
66
|
-
|
|
67
|
-
const theme =
|
|
66
|
+
Odac.storage('theme', 'dark')
|
|
67
|
+
const theme = Odac.storage('theme')
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
### 4. Programmatic Navigation
|
|
@@ -84,4 +84,4 @@ Odac.load('/dashboard', function(page, vars) {
|
|
|
84
84
|
## Best Practices
|
|
85
85
|
- **Clean Selectors**: Use ID or specific data-attributes for event listeners to avoid conflicts.
|
|
86
86
|
- **No Inline JS**: Move all logic from HTML attributes (onclick, etc.) into `Odac.action()`.
|
|
87
|
-
- **Shared State**: Use `
|
|
87
|
+
- **Shared State**: Use `Odac.data()` to pass complex objects from the backend once per request.
|
|
@@ -1,36 +1,92 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: frontend-scripts-typescript-skill
|
|
3
|
-
description: ODAC frontend JS/TS pipeline guidelines for writing, bundling, and optimizing client-side scripts using esbuild.
|
|
3
|
+
description: ODAC frontend JS/TS pipeline guidelines for writing, bundling, and optimizing client-side scripts using esbuild and code obfuscation.
|
|
4
4
|
metadata:
|
|
5
|
-
tags: frontend, javascript, typescript, esbuild, bundling, minification, tree-shaking, scripts, assets
|
|
5
|
+
tags: frontend, javascript, typescript, esbuild, bundling, minification, tree-shaking, scripts, assets, obfuscation
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Frontend Scripts & TypeScript Skill
|
|
9
9
|
|
|
10
|
-
Zero-
|
|
10
|
+
ODAC provides a built-in, Zero-Config frontend asset pipeline powered by **esbuild** for TypeScript transpilation, bundling, minification, tree-shaking, and multi-level code obfuscation.
|
|
11
11
|
|
|
12
|
-
## Core Rules
|
|
13
|
-
1. **Entry Points**: Place `.ts`, `.js`, `.mts`, or `.mjs` files in `view/js/`. Each becomes a separate bundle.
|
|
14
|
-
2. **Partials Convention**: Files starting with `_` (e.g., `_utils.ts`) are ignored as entry points — use them as shared imports only.
|
|
15
|
-
3. **Output Path**: Compiled files go to `public/assets/js/{name}.js`.
|
|
16
|
-
4. **No TypeScript Enforcement**: Both TypeScript and plain JavaScript are supported equally.
|
|
17
|
-
5. **Import Resolution**: Use standard ES module `import`/`export` between files. esbuild bundles everything into a single output per entry point.
|
|
18
|
-
6. **Configuration**: Optional `js` key in `odac.json` for `target`, `minify`, `sourcemap`, and `bundle` settings.
|
|
12
|
+
## Core Rules & Conventions
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
1. **Entry Points**: Every `.ts`, `.js`, `.mts`, or `.mjs` file placed directly in `view/js/` represents a unique entry point and will compile into a separate bundle in `public/assets/js/{name}.js`.
|
|
15
|
+
2. **Partials Convention**: Files starting with an underscore (e.g., `_utils.ts`, `_api.ts`) are treated as private modules/partials. They are **ignored** as entry points and should only be used as shared imports.
|
|
16
|
+
3. **No TypeScript Enforcement**: TypeScript and plain JavaScript are supported equally out of the box.
|
|
17
|
+
4. **Import Resolution**: Use standard ES module `import`/`export` syntax. esbuild bundles all imported modules into a single optimized bundle, eliminating extra runtime network requests.
|
|
18
|
+
5. **Output Path**: Compiled output is saved statically under `public/assets/js/`.
|
|
19
|
+
|
|
20
|
+
## Pipeline Modes
|
|
21
|
+
|
|
22
|
+
* **Development (`npm run dev` / `odac dev`)**:
|
|
23
|
+
* Watches all scripts in `view/js/` for instant sub-millisecond rebuilds.
|
|
24
|
+
* Source maps are always enabled to facilitate easy debugging.
|
|
25
|
+
* No minification or obfuscation is applied.
|
|
26
|
+
* **Production (`npm run build` / `odac build`)**:
|
|
27
|
+
* Enables full bundling, minification, and tree-shaking.
|
|
28
|
+
* Applies configured obfuscation levels.
|
|
29
|
+
* Exports clean production-ready assets to `public/assets/js/`.
|
|
30
|
+
|
|
31
|
+
## Configuration (`odac.json`)
|
|
32
|
+
|
|
33
|
+
You can customize the pipeline behavior via the optional `js` key in the `odac.json` configuration file:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"js": {
|
|
38
|
+
"target": "es2020",
|
|
39
|
+
"minify": true,
|
|
40
|
+
"sourcemap": false,
|
|
41
|
+
"bundle": true,
|
|
42
|
+
"obfuscate": false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Configuration Options
|
|
48
|
+
|
|
49
|
+
| Option | Default | Description |
|
|
50
|
+
|-------------|------------|-------------|
|
|
51
|
+
| `target` | `"es2020"` | JavaScript target version (`es2015`, `es2020`, `esnext`, etc.). |
|
|
52
|
+
| `minify` | `true` | Enables minification (whitespace removal, variable shortening, dead code elimination) in production. |
|
|
53
|
+
| `sourcemap` | `false` | Generates source maps in production builds (always enabled in dev mode). |
|
|
54
|
+
| `bundle` | `true` | Bundles all imported dependency modules into the output entry-point file. |
|
|
55
|
+
| `obfuscate` | `false` | Configures the level of production code obfuscation (`false`, `true`/`"low"`, `"medium"`, `"high"`). |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Obfuscation Levels
|
|
60
|
+
|
|
61
|
+
ODAC supports three distinct levels of code obfuscation in production mode (`odac build`). Obfuscation is disabled by default and is never applied during development.
|
|
62
|
+
|
|
63
|
+
| Level | Behavior |
|
|
64
|
+
|-------|----------|
|
|
65
|
+
| `false` | **No Obfuscation**: Standard minification and tree-shaking only. |
|
|
66
|
+
| `true` / `"low"` | **Low Mangling**: Mangles properties starting with `_` (private-by-convention). |
|
|
67
|
+
| `"medium"` | **Medium Security**: Low level mangling + drops `debugger` statements + removes `console.debug` and `console.trace` calls. |
|
|
68
|
+
| `"high"` | **Maximum Hardening**: Mangles all `_` and `$` prefixed properties + drops all `console.*` calls and `debugger` statements. |
|
|
69
|
+
|
|
70
|
+
> [!WARNING]
|
|
71
|
+
> **High Obfuscation Compatibility Warning:**
|
|
72
|
+
> The `"high"` obfuscation level mangles `$`-prefixed properties. This can break frontend code interacting with external libraries or frameworks that rely heavily on the `$` naming convention (e.g., jQuery). Start with `"low"` or `"medium"` and verify compatibility thoroughly before deploying with `"high"`.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Example Directory Structure
|
|
23
77
|
|
|
24
|
-
## Example Structure
|
|
25
78
|
```
|
|
26
79
|
view/js/
|
|
27
|
-
├── app.ts → public/assets/js/app.js
|
|
28
|
-
├── admin.ts → public/assets/js/admin.js
|
|
29
|
-
├── _api.ts (
|
|
30
|
-
└── _utils.ts (
|
|
80
|
+
├── app.ts → compiled to public/assets/js/app.js (Entry Point)
|
|
81
|
+
├── admin.ts → compiled to public/assets/js/admin.js (Entry Point)
|
|
82
|
+
├── _api.ts (Shared API Module — Import only, not compiled on its own)
|
|
83
|
+
└── _utils.ts (Shared Utility Module — Import only, not compiled on its own)
|
|
31
84
|
```
|
|
32
85
|
|
|
33
86
|
## HTML Integration
|
|
87
|
+
|
|
88
|
+
Inject compiled scripts into your skeleton or layout templates using regular script tags:
|
|
89
|
+
|
|
34
90
|
```html
|
|
35
91
|
<script src="/assets/js/app.js"></script>
|
|
36
92
|
```
|
|
@@ -15,7 +15,7 @@ Remember the `Odac` object? It's your best friend inside a controller. It's pass
|
|
|
15
15
|
* `Odac.return(data)`: Send back a response.
|
|
16
16
|
* `Odac.direct(url)`: Redirect the user to a new page.
|
|
17
17
|
* `Odac.set(key, value)`: Pass variables to your View template.
|
|
18
|
-
* `Odac.share(key, value)`: Share data directly with frontend JavaScript (`
|
|
18
|
+
* `Odac.share(key, value)`: Share data directly with frontend JavaScript (`Odac.data()`).
|
|
19
19
|
* `Odac.cookie(key, value)`: Set a browser cookie.
|
|
20
20
|
* `Odac.validator()`: Check user input easily.
|
|
21
21
|
* `Odac.setInterval(callback, delay)`: Schedule repeating tasks (auto-cleanup).
|
|
@@ -204,6 +204,50 @@ Automatically set field values without user input:
|
|
|
204
204
|
<odac:submit text="Save" loading="Saving..." class="btn btn-primary" id="save-btn"/>
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
## Client-Side Callbacks (Optional)
|
|
208
|
+
|
|
209
|
+
`<odac:form>` works fully on its own — submission, CSRF, validation, loading
|
|
210
|
+
states, success/error messages and redirects are all wired up automatically,
|
|
211
|
+
**no JavaScript required.**
|
|
212
|
+
|
|
213
|
+
If you also need to run your own code after a submit (analytics, closing a
|
|
214
|
+
modal, updating the UI, etc.), give the form an `id` and attach a callback with
|
|
215
|
+
`Odac.form()`:
|
|
216
|
+
|
|
217
|
+
```html
|
|
218
|
+
<odac:form action="Account.unlink" id="server-unlink">
|
|
219
|
+
<!-- fields -->
|
|
220
|
+
<odac:submit text="Unlink"/>
|
|
221
|
+
</odac:form>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```javascript
|
|
225
|
+
Odac.form('#server-unlink', function(data) {
|
|
226
|
+
if (data.result.success) {
|
|
227
|
+
closeModal()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
This is **safe to combine** with the automatic registration — calling
|
|
233
|
+
`Odac.form()` on an `<odac:form>` re-binds the same form rather than adding a
|
|
234
|
+
second handler, so the form still submits only once. (Internally the dedup is
|
|
235
|
+
keyed by the form element, not the selector string.)
|
|
236
|
+
|
|
237
|
+
> **Note:** Call `Odac.form()` after the form exists in the DOM (e.g. on
|
|
238
|
+
> `DOMContentLoaded` or after AJAX navigation). Binding before the element is
|
|
239
|
+
> rendered can leave the automatic registration in place as a second handler.
|
|
240
|
+
|
|
241
|
+
You can trigger submission programmatically with the native API:
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
document.getElementById('server-unlink').requestSubmit()
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
For the full callback/options API (`messages`, `loading`, redirect strings,
|
|
248
|
+
file-upload progress) see
|
|
249
|
+
[Form Handling with odac.js](../../frontend/03-forms/01-form-handling.md).
|
|
250
|
+
|
|
207
251
|
## Controller Handler (Server Actions)
|
|
208
252
|
|
|
209
253
|
Handle form submission directly in your controller. The action is defined as `ControllerName.methodName`.
|
|
@@ -88,13 +88,13 @@ Odac.action({
|
|
|
88
88
|
|
|
89
89
|
```javascript
|
|
90
90
|
// Basic usage
|
|
91
|
-
|
|
91
|
+
Odac.form('#my-form', function(data) {
|
|
92
92
|
// This callback is executed on success
|
|
93
93
|
console.log('Form submitted successfully!', data);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
// With options
|
|
97
|
-
|
|
97
|
+
Odac.form({
|
|
98
98
|
form: '#my-form',
|
|
99
99
|
messages: ['success', 'error'], // Show both success and error messages
|
|
100
100
|
loading: function(percent) {
|
|
@@ -119,7 +119,7 @@ To display validation errors, you can add elements with the `odac-form-error` at
|
|
|
119
119
|
For simple GET requests, you can use the `Odac.get()` method.
|
|
120
120
|
|
|
121
121
|
```javascript
|
|
122
|
-
|
|
122
|
+
Odac.get('/api/users', function(data) {
|
|
123
123
|
console.log('Users:', data);
|
|
124
124
|
});
|
|
125
125
|
```
|
|
@@ -133,18 +133,18 @@ odac.get('/api/users', function(data) {
|
|
|
133
133
|
- **`Odac.client()`**: Returns a unique client identifier from a cookie.
|
|
134
134
|
- **`Odac.data(key)`**: Returns shared data passed from the backend via `Odac.share`. You can get the full data object or a specific key:
|
|
135
135
|
```javascript
|
|
136
|
-
let allData =
|
|
137
|
-
let user =
|
|
136
|
+
let allData = Odac.data();
|
|
137
|
+
let user = Odac.data('user'); // Returns null if not exists
|
|
138
138
|
```
|
|
139
139
|
- **`Odac.page()`**: Returns the identifier of the current page. This is the controller name (e.g., `'user'`) or view name (e.g., `'dashboard'`) set by the backend. Use this to conditionally run code for specific pages.
|
|
140
140
|
- **`Odac.storage()`**: A wrapper for `localStorage`.
|
|
141
141
|
```javascript
|
|
142
142
|
// Set a value
|
|
143
|
-
|
|
143
|
+
Odac.storage('my-key', 'my-value');
|
|
144
144
|
|
|
145
145
|
// Get a value
|
|
146
|
-
let value =
|
|
146
|
+
let value = Odac.storage('my-key');
|
|
147
147
|
|
|
148
148
|
// Remove a value
|
|
149
|
-
|
|
149
|
+
Odac.storage('my-key', null);
|
|
150
150
|
```
|
|
@@ -126,17 +126,17 @@ Controllers automatically support AJAX loading. Use `Odac.View.skeleton()` to sp
|
|
|
126
126
|
```javascript
|
|
127
127
|
module.exports = function (Odac) {
|
|
128
128
|
// Define the skeleton template
|
|
129
|
-
|
|
129
|
+
Odac.View.skeleton('main')
|
|
130
130
|
|
|
131
131
|
// Set view parts - lowercase keys map to UPPERCASE placeholders
|
|
132
|
-
|
|
132
|
+
Odac.View.set({
|
|
133
133
|
header: 'main', // Loads view/header/main.html into {{ HEADER }}
|
|
134
134
|
content: 'about', // Loads view/content/about.html into {{ CONTENT }}
|
|
135
135
|
footer: 'main' // Loads view/footer/main.html into {{ FOOTER }}
|
|
136
136
|
})
|
|
137
137
|
|
|
138
138
|
// Optional: Send variables to frontend (AJAX only)
|
|
139
|
-
|
|
139
|
+
Odac.set({
|
|
140
140
|
pageTitle: 'About',
|
|
141
141
|
data: {foo: 'bar'}
|
|
142
142
|
}, true) // true = include in AJAX responses
|
|
@@ -318,13 +318,13 @@ Both methods work automatically - no additional configuration needed!
|
|
|
318
318
|
}
|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
###
|
|
321
|
+
### Odac.loader(selector, elements, callback)
|
|
322
322
|
|
|
323
323
|
Low-level method for direct initialization (not recommended for new code).
|
|
324
324
|
|
|
325
325
|
**Parameters:** Same as navigate configuration, but as separate arguments.
|
|
326
326
|
|
|
327
|
-
###
|
|
327
|
+
### Odac.load(url, callback, push)
|
|
328
328
|
|
|
329
329
|
Programmatically load a page via AJAX.
|
|
330
330
|
|
|
@@ -335,7 +335,7 @@ Programmatically load a page via AJAX.
|
|
|
335
335
|
|
|
336
336
|
**Example:**
|
|
337
337
|
```javascript
|
|
338
|
-
|
|
338
|
+
Odac.load('/about', function(page, variables) {
|
|
339
339
|
console.log('Loaded:', page)
|
|
340
340
|
})
|
|
341
341
|
```
|
|
@@ -377,7 +377,7 @@ Send data from server to client in AJAX responses:
|
|
|
377
377
|
|
|
378
378
|
```javascript
|
|
379
379
|
// In controller
|
|
380
|
-
|
|
380
|
+
Odac.set({
|
|
381
381
|
user: {name: 'John', role: 'admin'},
|
|
382
382
|
stats: {views: 1234}
|
|
383
383
|
}, true) // true = include in AJAX
|
|
@@ -423,12 +423,12 @@ Odac.action({
|
|
|
423
423
|
navigate: {
|
|
424
424
|
update: 'main',
|
|
425
425
|
on: function(page, variables) {
|
|
426
|
-
|
|
426
|
+
Odac.fn.updateActiveNav(window.location.pathname)
|
|
427
427
|
console.log('Navigated to:', page)
|
|
428
428
|
}
|
|
429
429
|
},
|
|
430
430
|
|
|
431
|
-
// Custom functions (accessible as
|
|
431
|
+
// Custom functions (accessible as Odac.fn.functionName)
|
|
432
432
|
function: {
|
|
433
433
|
updateActiveNav: function(url) {
|
|
434
434
|
document.querySelectorAll('nav a').forEach(link => {
|
|
@@ -440,14 +440,14 @@ Odac.action({
|
|
|
440
440
|
// App initialization
|
|
441
441
|
load: function() {
|
|
442
442
|
console.log('App initialized')
|
|
443
|
-
|
|
443
|
+
Odac.fn.updateActiveNav(window.location.pathname)
|
|
444
444
|
},
|
|
445
445
|
|
|
446
446
|
// Page-specific code
|
|
447
447
|
page: {
|
|
448
448
|
index: function(variables) {
|
|
449
449
|
// Home page specific code
|
|
450
|
-
|
|
450
|
+
Odac.form('#contact-form', function(data) {
|
|
451
451
|
if (data.result.success) {
|
|
452
452
|
alert('Message sent!')
|
|
453
453
|
}
|
|
@@ -463,7 +463,7 @@ Odac.action({
|
|
|
463
463
|
// Event handlers
|
|
464
464
|
click: {
|
|
465
465
|
'#refresh-btn': function() {
|
|
466
|
-
|
|
466
|
+
Odac.load(window.location.pathname)
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
469
|
})
|
|
@@ -595,8 +595,8 @@ This is usually caused by mismatched keys between your skeleton, controller, and
|
|
|
595
595
|
|
|
596
596
|
2. **Controller** (`controller/page/about.js`):
|
|
597
597
|
```javascript
|
|
598
|
-
|
|
599
|
-
|
|
598
|
+
Odac.View.skeleton('main')
|
|
599
|
+
Odac.View.set({
|
|
600
600
|
header: 'main', // Lowercase → {{ HEADER }}
|
|
601
601
|
content: 'about' // Lowercase → {{ CONTENT }}
|
|
602
602
|
})
|
|
@@ -129,7 +129,7 @@ The keys (content, header, etc.) must match the view parts defined in your contr
|
|
|
129
129
|
|
|
130
130
|
```javascript
|
|
131
131
|
// In controller
|
|
132
|
-
|
|
132
|
+
Odac.View.set({
|
|
133
133
|
header: 'main',
|
|
134
134
|
content: 'about',
|
|
135
135
|
sidebar: 'main'
|
|
@@ -219,14 +219,14 @@ In your controller, send data to the client:
|
|
|
219
219
|
```javascript
|
|
220
220
|
module.exports = function(Odac) {
|
|
221
221
|
// Set variables for AJAX responses
|
|
222
|
-
|
|
222
|
+
Odac.set({
|
|
223
223
|
title: 'About Page',
|
|
224
224
|
user: {name: 'John', role: 'admin'},
|
|
225
225
|
stats: {views: 1234}
|
|
226
226
|
}, true) // true = include in AJAX
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
Odac.View.skeleton('main')
|
|
229
|
+
Odac.View.set({
|
|
230
230
|
header: 'main',
|
|
231
231
|
content: 'about',
|
|
232
232
|
footer: 'main'
|
|
@@ -6,31 +6,31 @@ Advanced techniques and patterns for AJAX navigation in Odac.
|
|
|
6
6
|
|
|
7
7
|
## Programmatic Navigation
|
|
8
8
|
|
|
9
|
-
### Using
|
|
9
|
+
### Using Odac.load()
|
|
10
10
|
|
|
11
11
|
Navigate programmatically from your code:
|
|
12
12
|
|
|
13
13
|
```javascript
|
|
14
14
|
// Basic usage
|
|
15
|
-
|
|
15
|
+
Odac.load('/about')
|
|
16
16
|
|
|
17
17
|
// With callback
|
|
18
|
-
|
|
18
|
+
Odac.load('/about', function(page, variables) {
|
|
19
19
|
console.log('Loaded:', page)
|
|
20
20
|
console.log('Data:', variables)
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
// Without updating history
|
|
24
|
-
|
|
24
|
+
Odac.load('/about', callback, false)
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
### Use Cases
|
|
28
28
|
|
|
29
29
|
**Redirect after form submission:**
|
|
30
30
|
```javascript
|
|
31
|
-
|
|
31
|
+
Odac.form('#my-form', function(data) {
|
|
32
32
|
if (data.result.success) {
|
|
33
|
-
|
|
33
|
+
Odac.load('/success')
|
|
34
34
|
}
|
|
35
35
|
})
|
|
36
36
|
```
|
|
@@ -38,16 +38,16 @@ odac.form('#my-form', function(data) {
|
|
|
38
38
|
**Conditional navigation:**
|
|
39
39
|
```javascript
|
|
40
40
|
if (user.isLoggedIn) {
|
|
41
|
-
|
|
41
|
+
Odac.load('/dashboard')
|
|
42
42
|
} else {
|
|
43
|
-
|
|
43
|
+
Odac.load('/login')
|
|
44
44
|
}
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
**Timed navigation:**
|
|
48
48
|
```javascript
|
|
49
49
|
setTimeout(() => {
|
|
50
|
-
|
|
50
|
+
Odac.load('/next-page')
|
|
51
51
|
}, 3000)
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -76,8 +76,8 @@ Define all parts in your controller:
|
|
|
76
76
|
|
|
77
77
|
```javascript
|
|
78
78
|
module.exports = function(Odac) {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
Odac.View.skeleton('main')
|
|
80
|
+
Odac.View.set({
|
|
81
81
|
header: 'main',
|
|
82
82
|
content: 'dashboard',
|
|
83
83
|
sidebar: 'dashboard',
|
|
@@ -333,10 +333,10 @@ Odac.action({
|
|
|
333
333
|
Gracefully handle navigation errors:
|
|
334
334
|
|
|
335
335
|
```javascript
|
|
336
|
-
// Override
|
|
337
|
-
const originalLoad =
|
|
336
|
+
// Override Odac.load to add error handling
|
|
337
|
+
const originalLoad = Odac.load.bind(Odac)
|
|
338
338
|
|
|
339
|
-
|
|
339
|
+
Odac.load = function(url, callback, push) {
|
|
340
340
|
try {
|
|
341
341
|
originalLoad(url, function(page, variables) {
|
|
342
342
|
if (callback) callback(page, variables)
|
|
@@ -359,7 +359,7 @@ function loadWithRetry(url, maxRetries = 3) {
|
|
|
359
359
|
|
|
360
360
|
function attempt() {
|
|
361
361
|
attempts++
|
|
362
|
-
|
|
362
|
+
Odac.load(url,
|
|
363
363
|
(page, vars) => {
|
|
364
364
|
console.log('Success after', attempts, 'attempts')
|
|
365
365
|
},
|
|
@@ -491,7 +491,7 @@ describe('Navigation', () => {
|
|
|
491
491
|
})
|
|
492
492
|
|
|
493
493
|
// Simulate navigation
|
|
494
|
-
|
|
494
|
+
Odac.load('/about')
|
|
495
495
|
|
|
496
496
|
// Assert
|
|
497
497
|
expect(document.querySelector('.nav-link.active').href)
|
|
@@ -14,7 +14,7 @@ Learn how to handle forms with automatic AJAX submission, CSRF protection, and v
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
```javascript
|
|
17
|
-
|
|
17
|
+
Odac.form('#contact-form', function(data) {
|
|
18
18
|
if (data.result.success) {
|
|
19
19
|
alert('Form submitted successfully!')
|
|
20
20
|
}
|
|
@@ -35,7 +35,7 @@ That's it! odac.js handles:
|
|
|
35
35
|
### Basic Usage
|
|
36
36
|
|
|
37
37
|
```javascript
|
|
38
|
-
|
|
38
|
+
Odac.form('#my-form', function(data) {
|
|
39
39
|
console.log('Response:', data)
|
|
40
40
|
})
|
|
41
41
|
```
|
|
@@ -43,7 +43,7 @@ odac.form('#my-form', function(data) {
|
|
|
43
43
|
### With Options
|
|
44
44
|
|
|
45
45
|
```javascript
|
|
46
|
-
|
|
46
|
+
Odac.form({
|
|
47
47
|
form: '#my-form',
|
|
48
48
|
messages: true, // Show error/success messages
|
|
49
49
|
loading: function(percent) {
|
|
@@ -59,10 +59,37 @@ odac.form({
|
|
|
59
59
|
### Redirect After Submit
|
|
60
60
|
|
|
61
61
|
```javascript
|
|
62
|
-
|
|
62
|
+
Odac.form('#my-form', '/success-page')
|
|
63
63
|
// Redirects to /success-page on success
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
## Using with `<odac:form>` Components
|
|
67
|
+
|
|
68
|
+
`Odac.form()` works with both plain `<form>` elements and server-rendered
|
|
69
|
+
[`<odac:form>` components](../../backend/05-forms/01-custom-forms.md).
|
|
70
|
+
|
|
71
|
+
An `<odac:form>` already registers itself automatically, so you only call
|
|
72
|
+
`Odac.form()` when you want to add your own callback. Give the component an `id`
|
|
73
|
+
and bind to it:
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<odac:form action="Contact.submit" id="contact-form">
|
|
77
|
+
<!-- fields -->
|
|
78
|
+
<odac:submit text="Send"/>
|
|
79
|
+
</odac:form>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
Odac.form('#contact-form', function(data) {
|
|
84
|
+
if (data.result.success) {
|
|
85
|
+
closeModal()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Calling `Odac.form()` on an already-registered `<odac:form>` **re-binds** the
|
|
91
|
+
same form instead of adding a second handler, so it still submits only once.
|
|
92
|
+
|
|
66
93
|
## Error Handling
|
|
67
94
|
|
|
68
95
|
### Automatic Error Display
|
|
@@ -85,7 +112,7 @@ If you want to control where errors appear, add error elements manually:
|
|
|
85
112
|
### Custom Error Display
|
|
86
113
|
|
|
87
114
|
```javascript
|
|
88
|
-
|
|
115
|
+
Odac.form('#my-form', function(data) {
|
|
89
116
|
if (!data.result.success) {
|
|
90
117
|
// Custom error handling
|
|
91
118
|
Object.entries(data.errors).forEach(([field, message]) => {
|
|
@@ -137,7 +164,7 @@ If you want to control where success messages appear, add a success element manu
|
|
|
137
164
|
### Custom Success Message
|
|
138
165
|
|
|
139
166
|
```javascript
|
|
140
|
-
|
|
167
|
+
Odac.form('#my-form', function(data) {
|
|
141
168
|
if (data.result.success) {
|
|
142
169
|
document.querySelector('#custom-message').innerHTML =
|
|
143
170
|
'Thank you! Your form has been submitted.'
|
|
@@ -157,7 +184,7 @@ odac.form('#my-form', function(data) {
|
|
|
157
184
|
```
|
|
158
185
|
|
|
159
186
|
```javascript
|
|
160
|
-
|
|
187
|
+
Odac.form('#upload-form', function(data) {
|
|
161
188
|
if (data.result.success) {
|
|
162
189
|
console.log('File uploaded:', data.result.filename)
|
|
163
190
|
}
|
|
@@ -167,7 +194,7 @@ odac.form('#upload-form', function(data) {
|
|
|
167
194
|
### Upload Progress
|
|
168
195
|
|
|
169
196
|
```javascript
|
|
170
|
-
|
|
197
|
+
Odac.form({
|
|
171
198
|
form: '#upload-form',
|
|
172
199
|
loading: function(percent) {
|
|
173
200
|
document.querySelector('#progress').style.width = percent + '%'
|
|
@@ -189,7 +216,7 @@ odac.form({
|
|
|
189
216
|
### Disable Messages
|
|
190
217
|
|
|
191
218
|
```javascript
|
|
192
|
-
|
|
219
|
+
Odac.form({
|
|
193
220
|
form: '#my-form',
|
|
194
221
|
messages: false // Don't show automatic messages
|
|
195
222
|
}, function(data) {
|
|
@@ -201,7 +228,7 @@ odac.form({
|
|
|
201
228
|
|
|
202
229
|
```javascript
|
|
203
230
|
// Only show error messages, suppress success messages
|
|
204
|
-
|
|
231
|
+
Odac.form({
|
|
205
232
|
form: '#my-form',
|
|
206
233
|
messages: ['error']
|
|
207
234
|
}, function(data) {
|
|
@@ -211,7 +238,7 @@ odac.form({
|
|
|
211
238
|
})
|
|
212
239
|
|
|
213
240
|
// Only show success messages, suppress error messages
|
|
214
|
-
|
|
241
|
+
Odac.form({
|
|
215
242
|
form: '#my-form',
|
|
216
243
|
messages: ['success']
|
|
217
244
|
}, function(data) {
|
|
@@ -224,7 +251,7 @@ odac.form({
|
|
|
224
251
|
### Form Reset
|
|
225
252
|
|
|
226
253
|
```javascript
|
|
227
|
-
|
|
254
|
+
Odac.form('#my-form', function(data) {
|
|
228
255
|
if (data.result.success) {
|
|
229
256
|
// Reset the form
|
|
230
257
|
document.querySelector('#my-form').reset()
|
|
@@ -242,7 +269,7 @@ document.querySelector('#my-form').addEventListener('submit', function(e) {
|
|
|
242
269
|
}
|
|
243
270
|
})
|
|
244
271
|
|
|
245
|
-
|
|
272
|
+
Odac.form('#my-form', function(data) {
|
|
246
273
|
console.log('Submitted!')
|
|
247
274
|
})
|
|
248
275
|
```
|
|
@@ -254,8 +281,8 @@ odac.form('#my-form', function(data) {
|
|
|
254
281
|
```javascript
|
|
255
282
|
// controller/post/contact.js
|
|
256
283
|
module.exports = async function(Odac) {
|
|
257
|
-
const email = await
|
|
258
|
-
const message = await
|
|
284
|
+
const email = await Odac.Request.request('email')
|
|
285
|
+
const message = await Odac.Request.request('message')
|
|
259
286
|
|
|
260
287
|
// Validation
|
|
261
288
|
const errors = {}
|
|
@@ -285,7 +312,7 @@ module.exports = async function(Odac) {
|
|
|
285
312
|
|
|
286
313
|
```javascript
|
|
287
314
|
// route/www.js
|
|
288
|
-
|
|
315
|
+
Odac.Route.post('/api/contact', 'contact')
|
|
289
316
|
```
|
|
290
317
|
|
|
291
318
|
## Validation
|
|
@@ -306,7 +333,7 @@ Always validate on the server:
|
|
|
306
333
|
|
|
307
334
|
```javascript
|
|
308
335
|
module.exports = async function(Odac) {
|
|
309
|
-
const email = await
|
|
336
|
+
const email = await Odac.Request.request('email')
|
|
310
337
|
|
|
311
338
|
const errors = {}
|
|
312
339
|
|
|
@@ -349,7 +376,7 @@ module.exports = async function(Odac) {
|
|
|
349
376
|
**Note:** Error and success elements are auto-generated. Add them manually only if you need custom positioning or styling.
|
|
350
377
|
|
|
351
378
|
```javascript
|
|
352
|
-
|
|
379
|
+
Odac.form('#contact-form', function(data) {
|
|
353
380
|
if (data.result.success) {
|
|
354
381
|
document.querySelector('#contact-form').reset()
|
|
355
382
|
}
|
|
@@ -367,7 +394,7 @@ odac.form('#contact-form', function(data) {
|
|
|
367
394
|
```
|
|
368
395
|
|
|
369
396
|
```javascript
|
|
370
|
-
|
|
397
|
+
Odac.form('#login-form', function(data) {
|
|
371
398
|
if (data.result.success) {
|
|
372
399
|
// Redirect to dashboard
|
|
373
400
|
window.location.href = '/dashboard'
|
|
@@ -420,6 +447,15 @@ odac.form('#login-form', function(data) {
|
|
|
420
447
|
- Verify `messages` option is not set to `false`
|
|
421
448
|
- If using custom error elements, ensure `odac-form-error` attributes match field names exactly
|
|
422
449
|
|
|
450
|
+
### Form Submits Twice
|
|
451
|
+
|
|
452
|
+
- Make sure you don't register the **same form under two different selectors**
|
|
453
|
+
(e.g. `Odac.form('#my-form')` and `Odac.form('form#my-form')`) — use one
|
|
454
|
+
consistent selector.
|
|
455
|
+
- Binding `Odac.form()` to an `<odac:form>` is safe and will **not** double up;
|
|
456
|
+
it re-binds the existing handler. If you still see two submits, check that you
|
|
457
|
+
bind **after** the form is in the DOM, not before it renders.
|
|
458
|
+
|
|
423
459
|
### CSRF Token Errors
|
|
424
460
|
|
|
425
461
|
- CSRF tokens are handled automatically
|
|
@@ -7,7 +7,7 @@ Learn how to make GET and POST requests to your API endpoints using odac.js.
|
|
|
7
7
|
### Basic GET Request
|
|
8
8
|
|
|
9
9
|
```javascript
|
|
10
|
-
|
|
10
|
+
Odac.get('/api/data', function(response) {
|
|
11
11
|
console.log('Data:', response)
|
|
12
12
|
})
|
|
13
13
|
```
|
|
@@ -16,7 +16,7 @@ odac.get('/api/data', function(response) {
|
|
|
16
16
|
|
|
17
17
|
```javascript
|
|
18
18
|
const userId = 123
|
|
19
|
-
|
|
19
|
+
Odac.get(`/api/users/${userId}`, function(user) {
|
|
20
20
|
console.log('User:', user.name)
|
|
21
21
|
console.log('Email:', user.email)
|
|
22
22
|
})
|
|
@@ -25,7 +25,7 @@ odac.get(`/api/users/${userId}`, function(user) {
|
|
|
25
25
|
### Error Handling
|
|
26
26
|
|
|
27
27
|
```javascript
|
|
28
|
-
|
|
28
|
+
Odac.get('/api/data', function(response) {
|
|
29
29
|
if (response.error) {
|
|
30
30
|
console.error('Error:', response.error)
|
|
31
31
|
return
|
|
@@ -43,7 +43,7 @@ odac.get('/api/data', function(response) {
|
|
|
43
43
|
The recommended way to make POST requests is using `Odac.form()`:
|
|
44
44
|
|
|
45
45
|
```javascript
|
|
46
|
-
|
|
46
|
+
Odac.form('#my-form', function(data) {
|
|
47
47
|
if (data.result.success) {
|
|
48
48
|
console.log('Success!')
|
|
49
49
|
}
|
|
@@ -58,12 +58,12 @@ For custom POST requests without forms, use the internal AJAX method:
|
|
|
58
58
|
|
|
59
59
|
```javascript
|
|
60
60
|
// Note: This is an advanced pattern
|
|
61
|
-
// For most cases, use
|
|
61
|
+
// For most cases, use Odac.form() instead
|
|
62
62
|
|
|
63
63
|
const formData = new FormData()
|
|
64
64
|
formData.append('name', 'John')
|
|
65
65
|
formData.append('email', 'john@example.com')
|
|
66
|
-
formData.append('_token',
|
|
66
|
+
formData.append('_token', Odac.token())
|
|
67
67
|
|
|
68
68
|
fetch('/api/submit', {
|
|
69
69
|
method: 'POST',
|
|
@@ -83,12 +83,12 @@ odac.js automatically handles CSRF tokens:
|
|
|
83
83
|
|
|
84
84
|
```javascript
|
|
85
85
|
// Token is automatically included
|
|
86
|
-
|
|
86
|
+
Odac.get('/api/data', function(response) {
|
|
87
87
|
// ...
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
// Token is automatically included in forms
|
|
91
|
-
|
|
91
|
+
Odac.form('#my-form', function(data) {
|
|
92
92
|
// ...
|
|
93
93
|
})
|
|
94
94
|
```
|
|
@@ -98,7 +98,7 @@ odac.form('#my-form', function(data) {
|
|
|
98
98
|
If you need the token manually:
|
|
99
99
|
|
|
100
100
|
```javascript
|
|
101
|
-
const token =
|
|
101
|
+
const token = Odac.token()
|
|
102
102
|
console.log('Current token:', token)
|
|
103
103
|
```
|
|
104
104
|
|
|
@@ -108,7 +108,7 @@ console.log('Current token:', token)
|
|
|
108
108
|
|
|
109
109
|
```javascript
|
|
110
110
|
// Get user profile
|
|
111
|
-
|
|
111
|
+
Odac.get('/api/profile', function(profile) {
|
|
112
112
|
document.querySelector('#username').textContent = profile.name
|
|
113
113
|
document.querySelector('#email').textContent = profile.email
|
|
114
114
|
})
|
|
@@ -118,7 +118,7 @@ odac.get('/api/profile', function(profile) {
|
|
|
118
118
|
|
|
119
119
|
```javascript
|
|
120
120
|
// Get products
|
|
121
|
-
|
|
121
|
+
Odac.get('/api/products', function(products) {
|
|
122
122
|
const container = document.querySelector('#products')
|
|
123
123
|
|
|
124
124
|
products.forEach(product => {
|
|
@@ -143,7 +143,7 @@ searchInput.addEventListener('input', function() {
|
|
|
143
143
|
|
|
144
144
|
if (query.length < 3) return
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
Odac.get(`/api/search?q=${encodeURIComponent(query)}`, function(results) {
|
|
147
147
|
displayResults(results)
|
|
148
148
|
})
|
|
149
149
|
})
|
|
@@ -172,7 +172,7 @@ document.querySelector('#autocomplete').addEventListener('input', function() {
|
|
|
172
172
|
debounceTimer = setTimeout(() => {
|
|
173
173
|
if (value.length < 2) return
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
Odac.get(`/api/autocomplete?q=${value}`, function(suggestions) {
|
|
176
176
|
showSuggestions(suggestions)
|
|
177
177
|
})
|
|
178
178
|
}, 300)
|
|
@@ -200,7 +200,7 @@ function loadMore() {
|
|
|
200
200
|
loading = true
|
|
201
201
|
page++
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
Odac.get(`/api/posts?page=${page}`, function(posts) {
|
|
204
204
|
posts.forEach(post => {
|
|
205
205
|
appendPost(post)
|
|
206
206
|
})
|
|
@@ -214,7 +214,7 @@ function loadMore() {
|
|
|
214
214
|
```javascript
|
|
215
215
|
// Poll for updates every 30 seconds
|
|
216
216
|
setInterval(function() {
|
|
217
|
-
|
|
217
|
+
Odac.get('/api/notifications', function(notifications) {
|
|
218
218
|
updateNotificationBadge(notifications.count)
|
|
219
219
|
})
|
|
220
220
|
}, 30000)
|
|
@@ -239,7 +239,7 @@ module.exports = function(Odac) {
|
|
|
239
239
|
|
|
240
240
|
```javascript
|
|
241
241
|
// route/www.js
|
|
242
|
-
|
|
242
|
+
Odac.Route.get('/api/users', 'users')
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
### GET with Parameters
|
|
@@ -247,13 +247,13 @@ odac.Route.get('/api/users', 'users')
|
|
|
247
247
|
```javascript
|
|
248
248
|
// controller/get/user.js
|
|
249
249
|
module.exports = async function(Odac) {
|
|
250
|
-
const userId =
|
|
250
|
+
const userId = Odac.Request.data.url.id
|
|
251
251
|
|
|
252
252
|
// Fetch user from database
|
|
253
253
|
const user = await getUserById(userId)
|
|
254
254
|
|
|
255
255
|
if (!user) {
|
|
256
|
-
|
|
256
|
+
Odac.Request.status(404)
|
|
257
257
|
return {error: 'User not found'}
|
|
258
258
|
}
|
|
259
259
|
|
|
@@ -263,7 +263,7 @@ module.exports = async function(Odac) {
|
|
|
263
263
|
|
|
264
264
|
```javascript
|
|
265
265
|
// route/www.js
|
|
266
|
-
|
|
266
|
+
Odac.Route.get('/api/users/{id}', 'user')
|
|
267
267
|
```
|
|
268
268
|
|
|
269
269
|
### POST Endpoint
|
|
@@ -271,8 +271,8 @@ odac.Route.get('/api/users/{id}', 'user')
|
|
|
271
271
|
```javascript
|
|
272
272
|
// controller/post/create.js
|
|
273
273
|
module.exports = async function(Odac) {
|
|
274
|
-
const name = await
|
|
275
|
-
const email = await
|
|
274
|
+
const name = await Odac.Request.request('name')
|
|
275
|
+
const email = await Odac.Request.request('email')
|
|
276
276
|
|
|
277
277
|
// Validation
|
|
278
278
|
if (!name || !email) {
|
|
@@ -300,7 +300,7 @@ module.exports = async function(Odac) {
|
|
|
300
300
|
|
|
301
301
|
```javascript
|
|
302
302
|
// route/www.js
|
|
303
|
-
|
|
303
|
+
Odac.Route.post('/api/users/create', 'create')
|
|
304
304
|
```
|
|
305
305
|
|
|
306
306
|
## Response Format
|
|
@@ -357,7 +357,7 @@ function getCached(url, callback) {
|
|
|
357
357
|
return
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
Odac.get(url, function(data) {
|
|
361
361
|
cache[url] = data
|
|
362
362
|
callback(data)
|
|
363
363
|
})
|
|
@@ -386,7 +386,7 @@ function processQueue() {
|
|
|
386
386
|
processing = true
|
|
387
387
|
const {url, callback} = requestQueue.shift()
|
|
388
388
|
|
|
389
|
-
|
|
389
|
+
Odac.get(url, function(data) {
|
|
390
390
|
callback(data)
|
|
391
391
|
processing = false
|
|
392
392
|
processQueue()
|
|
@@ -403,7 +403,7 @@ function getWithRetry(url, callback, maxRetries = 3) {
|
|
|
403
403
|
function attempt() {
|
|
404
404
|
attempts++
|
|
405
405
|
|
|
406
|
-
|
|
406
|
+
Odac.get(url, function(data) {
|
|
407
407
|
if (data.error && attempts < maxRetries) {
|
|
408
408
|
setTimeout(attempt, 1000 * attempts)
|
|
409
409
|
} else {
|
|
@@ -6,7 +6,7 @@ Odac provides a simple client-side API for consuming Server-Sent Events (SSE) st
|
|
|
6
6
|
|
|
7
7
|
```javascript
|
|
8
8
|
// Simple callback
|
|
9
|
-
|
|
9
|
+
Odac.listen('/events', (data) => {
|
|
10
10
|
console.log('Received:', data)
|
|
11
11
|
})
|
|
12
12
|
```
|
|
@@ -14,7 +14,7 @@ odac.listen('/events', (data) => {
|
|
|
14
14
|
### With Options
|
|
15
15
|
|
|
16
16
|
```javascript
|
|
17
|
-
const stream =
|
|
17
|
+
const stream = Odac.listen('/events',
|
|
18
18
|
(data) => {
|
|
19
19
|
console.log('Message:', data)
|
|
20
20
|
},
|
|
@@ -34,7 +34,7 @@ stream.close()
|
|
|
34
34
|
|
|
35
35
|
```javascript
|
|
36
36
|
// Server sends: data: {"message": "Hello"}\n\n
|
|
37
|
-
|
|
37
|
+
Odac.listen('/events', (data) => {
|
|
38
38
|
console.log(data.message) // "Hello"
|
|
39
39
|
})
|
|
40
40
|
```
|
|
@@ -47,8 +47,8 @@ odac.listen('/events', (data) => {
|
|
|
47
47
|
|
|
48
48
|
```javascript
|
|
49
49
|
// Server
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
Odac.Route.get('/dashboard/stats', async (Odac) => {
|
|
51
|
+
Odac.stream((send) => {
|
|
52
52
|
const interval = setInterval(async () => {
|
|
53
53
|
const stats = await getServerStats()
|
|
54
54
|
send(stats)
|
|
@@ -62,7 +62,7 @@ odac.Route.get('/dashboard/stats', async (Odac) => {
|
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
// Client
|
|
65
|
-
|
|
65
|
+
Odac.listen('/dashboard/stats', (stats) => {
|
|
66
66
|
document.getElementById('cpu').textContent = stats.cpu + '%'
|
|
67
67
|
document.getElementById('memory').textContent = stats.memory + 'MB'
|
|
68
68
|
})
|
|
@@ -72,16 +72,16 @@ odac.listen('/dashboard/stats', (stats) => {
|
|
|
72
72
|
|
|
73
73
|
```javascript
|
|
74
74
|
// Server
|
|
75
|
-
|
|
76
|
-
const userId = await
|
|
75
|
+
Odac.Route.get('/notifications', async (Odac) => {
|
|
76
|
+
const userId = await Odac.request('userId')
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Odac.stream((send) => {
|
|
79
79
|
global.notificationStreams[userId] = { send }
|
|
80
80
|
})
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
// Client
|
|
84
|
-
|
|
84
|
+
Odac.listen('/notifications', (notification) => {
|
|
85
85
|
showToast(notification.message)
|
|
86
86
|
})
|
|
87
87
|
```
|
|
@@ -90,8 +90,8 @@ odac.listen('/notifications', (notification) => {
|
|
|
90
90
|
|
|
91
91
|
```javascript
|
|
92
92
|
// Server
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
Odac.Route.get('/build/logs', async (Odac) => {
|
|
94
|
+
Odac.stream(async function* () {
|
|
95
95
|
for await (const log of getBuildLogs()) {
|
|
96
96
|
yield { timestamp: Date.now(), message: log }
|
|
97
97
|
}
|
|
@@ -100,7 +100,7 @@ odac.Route.get('/build/logs', async (Odac) => {
|
|
|
100
100
|
|
|
101
101
|
// Client
|
|
102
102
|
const logs = []
|
|
103
|
-
|
|
103
|
+
Odac.listen('/build/logs', (log) => {
|
|
104
104
|
logs.push(log)
|
|
105
105
|
updateLogsUI(logs)
|
|
106
106
|
})
|
|
@@ -108,7 +108,7 @@ odac.listen('/build/logs', (log) => {
|
|
|
108
108
|
|
|
109
109
|
## API Reference
|
|
110
110
|
|
|
111
|
-
###
|
|
111
|
+
### Odac.listen(url, onMessage, options)
|
|
112
112
|
|
|
113
113
|
**Parameters:**
|
|
114
114
|
- `url` (string): Stream endpoint URL
|
|
@@ -6,7 +6,7 @@ Odac provides built-in WebSocket support with automatic reconnection and cross-t
|
|
|
6
6
|
|
|
7
7
|
**Backend (route/main.js):**
|
|
8
8
|
```javascript
|
|
9
|
-
|
|
9
|
+
Odac.Route.ws('/chat', (ws, Odac) => {
|
|
10
10
|
ws.on('message', data => {
|
|
11
11
|
ws.broadcast(data)
|
|
12
12
|
})
|
package/index.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
global.__dir = process.cwd()
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
// Surface startup failures loudly. init() is async; without a .catch() any
|
|
6
|
+
// rejection (migration, DB connection, config parse) becomes an UnhandledPromiseRejection
|
|
7
|
+
// that the structured logger never sees — making a broken boot look like a clean start.
|
|
8
|
+
require('./src/Odac.js')
|
|
9
|
+
.init()
|
|
10
|
+
.catch(err => {
|
|
11
|
+
console.error('\x1b[31m[ODAC Fatal]\x1b[0m Startup failed:', err.message)
|
|
12
|
+
if (err.cause) console.error('\x1b[31m[ODAC Fatal]\x1b[0m Cause:', err.cause.message || err.cause)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
})
|
package/package.json
CHANGED
package/src/Request.js
CHANGED
|
@@ -50,12 +50,18 @@ class OdacRequest {
|
|
|
50
50
|
async abort(code) {
|
|
51
51
|
this.status(code)
|
|
52
52
|
let result = {401: 'Unauthorized', 404: 'Not Found', 408: 'Request Timeout'}[code] ?? null
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
const errorRoute = this.#odac.Route?.routes?.[this.route]?.error?.[code]
|
|
54
|
+
if (errorRoute && typeof errorRoute.cache === 'function') {
|
|
55
|
+
try {
|
|
56
|
+
const handlerResult = await errorRoute.cache(this.#odac)
|
|
57
|
+
// If the handler returned nothing, assume it configured the view via Odac.View.set()
|
|
58
|
+
// and let Route.request() continue to View.print() like normal pages.
|
|
59
|
+
if (handlerResult === undefined) return
|
|
60
|
+
result = handlerResult
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error(JSON.stringify({level: 'ERROR', message: `Error in custom error handler for ${code}`, error: e.message}))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
59
65
|
this.end(result)
|
|
60
66
|
}
|
|
61
67
|
|
package/src/Route.js
CHANGED
|
@@ -858,6 +858,13 @@ class Route {
|
|
|
858
858
|
}
|
|
859
859
|
|
|
860
860
|
error(code, file) {
|
|
861
|
+
if (typeof file === 'object' && file !== null && !Array.isArray(file)) {
|
|
862
|
+
this.set('error', code, _odac => {
|
|
863
|
+
_odac.set(file)
|
|
864
|
+
_odac.View.set(file)
|
|
865
|
+
})
|
|
866
|
+
return
|
|
867
|
+
}
|
|
861
868
|
this.set('error', code, file)
|
|
862
869
|
}
|
|
863
870
|
|
package/src/View/Form.js
CHANGED
|
@@ -58,6 +58,30 @@ class Form {
|
|
|
58
58
|
return String(value).replace(/[&<>"']/g, ch => map[ch])
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Like escapeHtml but leaves {{ ... }} / {!! ... !!} template tokens intact
|
|
62
|
+
// so they survive into the view engine's {{ }} pass. Escaping inside the
|
|
63
|
+
// tokens would corrupt the JS expression (e.g. ' -> ') and produce
|
|
64
|
+
// "Unexpected token '&'" at render time.
|
|
65
|
+
static escapeHtmlPreservingTemplates(value) {
|
|
66
|
+
if (value === null || value === undefined) return ''
|
|
67
|
+
const str = String(value)
|
|
68
|
+
const regex = /\{\{[\s\S]*?\}\}|\{!![\s\S]*?!!\}/g
|
|
69
|
+
let result = ''
|
|
70
|
+
let lastIndex = 0
|
|
71
|
+
let match
|
|
72
|
+
while ((match = regex.exec(str)) !== null) {
|
|
73
|
+
if (match.index > lastIndex) {
|
|
74
|
+
result += this.escapeHtml(str.substring(lastIndex, match.index))
|
|
75
|
+
}
|
|
76
|
+
result += match[0]
|
|
77
|
+
lastIndex = regex.lastIndex
|
|
78
|
+
}
|
|
79
|
+
if (lastIndex < str.length) {
|
|
80
|
+
result += this.escapeHtml(str.substring(lastIndex))
|
|
81
|
+
}
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
61
85
|
static parse(content, Odac) {
|
|
62
86
|
for (const type of this.FORM_TYPES) {
|
|
63
87
|
content = this.parseFormType(content, Odac, type)
|
|
@@ -484,16 +508,18 @@ class Form {
|
|
|
484
508
|
let html = ''
|
|
485
509
|
const escapedName = this.escapeHtml(field.name)
|
|
486
510
|
const escapedType = this.escapeHtml(field.type)
|
|
487
|
-
const escapedPlaceholder = this.
|
|
511
|
+
const escapedPlaceholder = this.escapeHtmlPreservingTemplates(field.placeholder)
|
|
488
512
|
|
|
489
513
|
if (field.label && field.type !== 'checkbox') {
|
|
490
|
-
const fieldId = this.
|
|
491
|
-
html += `<label for="${fieldId}">${this.
|
|
514
|
+
const fieldId = this.escapeHtmlPreservingTemplates(field.id || `odac-${field.name}`)
|
|
515
|
+
html += `<label for="${fieldId}">${this.escapeHtmlPreservingTemplates(field.label)}</label>\n`
|
|
492
516
|
}
|
|
493
517
|
|
|
494
|
-
const classAttr = field.class ? ` class="${this.
|
|
495
|
-
const idAttr = field.id
|
|
496
|
-
|
|
518
|
+
const classAttr = field.class ? ` class="${this.escapeHtmlPreservingTemplates(field.class)}"` : ''
|
|
519
|
+
const idAttr = field.id
|
|
520
|
+
? ` id="${this.escapeHtmlPreservingTemplates(field.id)}"`
|
|
521
|
+
: ` id="${this.escapeHtmlPreservingTemplates(`odac-${field.name}`)}"`
|
|
522
|
+
const valueAttr = field.value !== null ? ` value="${this.escapeHtmlPreservingTemplates(field.value)}"` : ''
|
|
497
523
|
|
|
498
524
|
if (field.type === 'checkbox') {
|
|
499
525
|
const attrs = this.buildHtml5Attributes(field)
|
|
@@ -501,14 +527,14 @@ class Form {
|
|
|
501
527
|
if (field.label) {
|
|
502
528
|
html += `<label>\n`
|
|
503
529
|
html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
504
|
-
html += ` ${this.
|
|
530
|
+
html += ` ${this.escapeHtmlPreservingTemplates(field.label)}\n`
|
|
505
531
|
html += `</label>\n`
|
|
506
532
|
} else {
|
|
507
533
|
html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
508
534
|
}
|
|
509
535
|
} else if (field.type === 'textarea') {
|
|
510
536
|
const attrs = this.buildHtml5Attributes(field)
|
|
511
|
-
html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.
|
|
537
|
+
html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtmlPreservingTemplates(
|
|
512
538
|
field.value || ''
|
|
513
539
|
)}</textarea>\n`
|
|
514
540
|
} else {
|
|
@@ -524,7 +550,7 @@ class Form {
|
|
|
524
550
|
for (const key in field.extraAttributes) {
|
|
525
551
|
const val = field.extraAttributes[key]
|
|
526
552
|
if (val === '') attrs += ` ${key}`
|
|
527
|
-
else attrs += ` ${key}="${this.
|
|
553
|
+
else attrs += ` ${key}="${this.escapeHtmlPreservingTemplates(val)}"`
|
|
528
554
|
}
|
|
529
555
|
return attrs
|
|
530
556
|
}
|