snice 3.6.0 → 3.8.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/README.md +2 -2
- package/bin/snice.js +4 -5
- package/bin/templates/CLAUDE.md +25 -3
- package/bin/templates/pwa/README.md +188 -0
- package/bin/templates/pwa/global.d.ts +10 -0
- package/bin/templates/pwa/index.html +16 -0
- package/bin/templates/pwa/package.json +32 -0
- package/bin/templates/pwa/public/icons/.gitkeep +6 -0
- package/bin/templates/pwa/src/daemons/notifications.ts +148 -0
- package/bin/templates/pwa/src/fetcher.ts +15 -0
- package/bin/templates/pwa/src/guards/auth.ts +12 -0
- package/bin/templates/pwa/src/main.ts +42 -0
- package/bin/templates/pwa/src/middleware/auth.ts +16 -0
- package/bin/templates/pwa/src/middleware/error.ts +36 -0
- package/bin/templates/pwa/src/middleware/retry.ts +31 -0
- package/bin/templates/pwa/src/pages/dashboard.ts +143 -0
- package/bin/templates/pwa/src/pages/login.ts +161 -0
- package/bin/templates/pwa/src/pages/notifications.ts +156 -0
- package/bin/templates/pwa/src/pages/profile.ts +164 -0
- package/bin/templates/pwa/src/router.ts +20 -0
- package/bin/templates/pwa/src/services/auth.ts +48 -0
- package/bin/templates/pwa/src/services/jwt.ts +35 -0
- package/bin/templates/pwa/src/services/storage.ts +24 -0
- package/bin/templates/pwa/src/styles/global.css +55 -0
- package/bin/templates/pwa/src/types/auth.ts +21 -0
- package/bin/templates/pwa/src/types/notifications.ts +9 -0
- package/bin/templates/pwa/tests/helpers/test-utils.ts +84 -0
- package/bin/templates/pwa/tests/middleware/auth.test.ts +67 -0
- package/bin/templates/pwa/tests/middleware/error.test.ts +105 -0
- package/bin/templates/pwa/tests/middleware/retry.test.ts +103 -0
- package/bin/templates/pwa/tests/services/auth.test.ts +89 -0
- package/bin/templates/pwa/tests/services/jwt.test.ts +76 -0
- package/bin/templates/pwa/tests/services/storage.test.ts +69 -0
- package/bin/templates/{social → pwa}/tsconfig.json +11 -10
- package/bin/templates/pwa/vite.config.ts +94 -0
- package/bin/templates/{social/vite.config.ts → pwa/vitest.config.ts} +12 -17
- package/dist/components/music-player/snice-music-player.d.ts +72 -0
- package/dist/components/music-player/snice-music-player.js +730 -0
- package/dist/components/music-player/snice-music-player.js.map +1 -0
- package/dist/components/music-player/snice-music-player.types.d.ts +43 -0
- package/dist/components/timer/snice-timer.d.ts +27 -0
- package/dist/components/timer/snice-timer.js +197 -0
- package/dist/components/timer/snice-timer.js.map +1 -0
- package/dist/components/timer/snice-timer.types.d.ts +10 -0
- package/dist/fetcher.d.ts +65 -0
- package/dist/index.cjs +92 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +92 -4
- package/dist/index.esm.js.map +1 -1
- package/dist/index.iife.js +92 -3
- package/dist/index.iife.js.map +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/dist/types/context.d.ts +7 -1
- package/dist/types/router-options.d.ts +6 -0
- package/docs/ai/api.md +33 -1
- package/docs/ai/components/music-player.md +134 -0
- package/docs/ai/components/timer.md +43 -0
- package/docs/ai/patterns.md +48 -1
- package/docs/components/music-player.md +314 -0
- package/docs/components/timer.md +143 -0
- package/docs/fetcher.md +447 -0
- package/docs/routing.md +11 -8
- package/package.json +2 -1
- package/bin/templates/social/README.md +0 -42
- package/bin/templates/social/global.d.ts +0 -14
- package/bin/templates/social/index.html +0 -13
- package/bin/templates/social/package.json +0 -21
- package/bin/templates/social/src/main.ts +0 -33
- package/bin/templates/social/src/pages/feed-page.ts +0 -111
- package/bin/templates/social/src/pages/messages-page.ts +0 -102
- package/bin/templates/social/src/pages/not-found-page.ts +0 -46
- package/bin/templates/social/src/pages/profile-page.ts +0 -99
- package/bin/templates/social/src/pages/settings-page.ts +0 -119
- package/bin/templates/social/src/router.ts +0 -9
- package/bin/templates/social/src/styles/global.css +0 -156
- /package/bin/templates/{social → pwa}/public/vite.svg +0 -0
package/README.md
CHANGED
|
@@ -416,10 +416,10 @@ When you modify the application context, call `update()` to notify all subscribe
|
|
|
416
416
|
```typescript
|
|
417
417
|
@page({ tag: 'login-page', routes: ['/login'] })
|
|
418
418
|
class LoginPage extends HTMLElement {
|
|
419
|
-
private ctx?: Context
|
|
419
|
+
private ctx?: Context;
|
|
420
420
|
|
|
421
421
|
@context()
|
|
422
|
-
handleContext(ctx: Context
|
|
422
|
+
handleContext(ctx: Context) {
|
|
423
423
|
this.ctx = ctx;
|
|
424
424
|
this.requestRender();
|
|
425
425
|
}
|
package/bin/snice.js
CHANGED
|
@@ -46,13 +46,12 @@ Options:
|
|
|
46
46
|
|
|
47
47
|
Templates:
|
|
48
48
|
base - Minimal starter with counter example (default)
|
|
49
|
-
|
|
49
|
+
pwa - Progressive Web App with auth, middleware, and live notifications
|
|
50
50
|
|
|
51
51
|
Examples:
|
|
52
52
|
snice create-app my-app
|
|
53
|
-
snice create-app my-app --template=
|
|
54
|
-
snice create-app --template=
|
|
55
|
-
npx snice create-app my-app --template=social
|
|
53
|
+
snice create-app my-app --template=pwa
|
|
54
|
+
npx snice create-app my-app --template=pwa
|
|
56
55
|
`);
|
|
57
56
|
}
|
|
58
57
|
|
|
@@ -90,7 +89,7 @@ function createApp(projectPath, template = 'base') {
|
|
|
90
89
|
// Check if template exists
|
|
91
90
|
if (!existsSync(templateDir)) {
|
|
92
91
|
console.error(`❌ Template "${template}" not found!`);
|
|
93
|
-
console.error(`Available templates: base,
|
|
92
|
+
console.error(`Available templates: base, pwa`);
|
|
94
93
|
process.exit(1);
|
|
95
94
|
}
|
|
96
95
|
|
package/bin/templates/CLAUDE.md
CHANGED
|
@@ -17,13 +17,23 @@ src/
|
|
|
17
17
|
pages/ # @page decorated route components
|
|
18
18
|
components/ # @element decorated UI components
|
|
19
19
|
controllers/ # @controller decorated behavior modules
|
|
20
|
+
utils/ # Pure functions, helpers (formatDate, debounce)
|
|
21
|
+
services/ # Stateless static functions for business logic/external APIs
|
|
22
|
+
daemons/ # Stateful classes with lifecycle (start/stop/dispose)
|
|
23
|
+
middleware/ # Composable middleware (fetch, logging, etc)
|
|
24
|
+
guards/ # Route guards
|
|
25
|
+
types/ # TypeScript types
|
|
20
26
|
styles/ # Global CSS
|
|
21
27
|
```
|
|
22
28
|
|
|
23
29
|
**Separation of concerns:**
|
|
24
|
-
- **Pages** - Orchestrate elements, handle URLs
|
|
25
|
-
- **
|
|
26
|
-
- **Controllers** -
|
|
30
|
+
- **Pages** - Orchestrate elements, handle URLs, most logic happens here
|
|
31
|
+
- **Components** - Pure presentation, no business logic
|
|
32
|
+
- **Controllers** - Attach to elements, add business behavior unsuitable for the page or components
|
|
33
|
+
- **Services** - Stateless business logic, API calls
|
|
34
|
+
- **Daemons** - Lifecycle-managed (WebSocket, P2P, intervals)
|
|
35
|
+
- **Middleware** - Composable functions (auth, retry)
|
|
36
|
+
- **Utils** - Pure helper functions
|
|
27
37
|
|
|
28
38
|
## Decorators
|
|
29
39
|
|
|
@@ -129,6 +139,18 @@ html`
|
|
|
129
139
|
- **Element ↔ Controller:** Request/Response (`@request`, `@respond`)
|
|
130
140
|
- **Global State:** Context (`@context()`)
|
|
131
141
|
|
|
142
|
+
## Common Mistakes
|
|
143
|
+
|
|
144
|
+
**Controllers are NOT global services.** They attach to elements via `controller="name"`. Use `utils/` for shared logic like API calls, auth, toasts.
|
|
145
|
+
|
|
146
|
+
**@request/@respond is NOT a service bus.** It's for element-to-element or element-to-controller communication only. Use utility functions for app-wide features.
|
|
147
|
+
|
|
148
|
+
**Guards receive Context, not AppContext.** Check `ctx.application.property`, not `ctx.property`. Guards: `(ctx: Context) => boolean`
|
|
149
|
+
|
|
150
|
+
**Context must be mutated then updated.** After changing `ctx.application`, call `ctx.update()` to notify subscribers. Pages need `@context()` to get context reference.
|
|
151
|
+
|
|
152
|
+
**@property is for parent-provided attributes.** Internal component state should be regular properties, not decorated. Only use `@property()` when the value comes from a parent element via attributes.
|
|
153
|
+
|
|
132
154
|
## Build Commands
|
|
133
155
|
|
|
134
156
|
```bash
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A Progressive Web App (PWA) built with [Snice](https://github.com/sniceio/snice).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ⚡ **JWT Authentication** - Token-based auth with automatic refresh
|
|
8
|
+
- 🔒 **Protected Routes** - Route guards for authenticated pages
|
|
9
|
+
- 🎯 **Middleware Pattern** - Composable fetch middleware (auth, error, retry)
|
|
10
|
+
- 📱 **PWA Ready** - Service worker, offline support, installable
|
|
11
|
+
- 🔔 **Live Notifications** - WebSocket daemon for real-time updates
|
|
12
|
+
- 🎨 **Snice Components** - Pre-built UI components
|
|
13
|
+
- 📦 **Type-Safe** - Full TypeScript support
|
|
14
|
+
- 🚀 **Fast Build** - Vite + SWC for blazing fast dev and builds
|
|
15
|
+
|
|
16
|
+
## Getting Started
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Install dependencies
|
|
20
|
+
npm install
|
|
21
|
+
|
|
22
|
+
# Start dev server
|
|
23
|
+
npm run dev
|
|
24
|
+
|
|
25
|
+
# Build for production
|
|
26
|
+
npm run build
|
|
27
|
+
|
|
28
|
+
# Preview production build
|
|
29
|
+
npm run preview
|
|
30
|
+
|
|
31
|
+
# Type check
|
|
32
|
+
npm run type-check
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Demo Credentials
|
|
36
|
+
|
|
37
|
+
- **Email:** demo@example.com
|
|
38
|
+
- **Password:** demo
|
|
39
|
+
|
|
40
|
+
## Project Structure
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
src/
|
|
44
|
+
utils/ # Pure helper functions
|
|
45
|
+
services/ # Business logic (auth, storage, jwt)
|
|
46
|
+
middleware/ # Fetch middleware (auth, error, retry)
|
|
47
|
+
daemons/ # Lifecycle-managed classes (notifications WebSocket)
|
|
48
|
+
guards/ # Route guards (auth)
|
|
49
|
+
types/ # TypeScript types
|
|
50
|
+
pages/ # Routable pages (@page decorator)
|
|
51
|
+
styles/ # Global styles
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Architecture Patterns
|
|
55
|
+
|
|
56
|
+
### Context-Aware Fetcher
|
|
57
|
+
|
|
58
|
+
Built-in middleware system with context access:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// fetcher.ts - Setup
|
|
62
|
+
import { ContextAwareFetcher } from 'snice';
|
|
63
|
+
|
|
64
|
+
const fetcher = new ContextAwareFetcher();
|
|
65
|
+
fetcher.use('request', authMiddleware);
|
|
66
|
+
fetcher.use('response', errorMiddleware);
|
|
67
|
+
|
|
68
|
+
// Middleware with context access
|
|
69
|
+
export async function authMiddleware(
|
|
70
|
+
this: Context,
|
|
71
|
+
request: Request,
|
|
72
|
+
next: () => Promise<Response>
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
const token = getToken();
|
|
75
|
+
if (token) {
|
|
76
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
77
|
+
}
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Usage in pages via ctx.fetch()
|
|
82
|
+
async loadData() {
|
|
83
|
+
const response = await this.ctx.fetch('/api/data');
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Daemons for Lifecycle Management
|
|
89
|
+
|
|
90
|
+
Use daemons for resources that need start/stop/dispose:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const daemon = getNotificationsDaemon();
|
|
94
|
+
daemon.start(); // In main.ts
|
|
95
|
+
|
|
96
|
+
// In component
|
|
97
|
+
const unsubscribe = daemon.subscribe((notification) => {
|
|
98
|
+
console.log(notification);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Cleanup
|
|
102
|
+
unsubscribe();
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Route Guards
|
|
106
|
+
|
|
107
|
+
Protect routes with guards:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { authGuard } from './guards/auth';
|
|
111
|
+
|
|
112
|
+
@page({
|
|
113
|
+
tag: 'dashboard-page',
|
|
114
|
+
routes: ['/dashboard'],
|
|
115
|
+
guards: [authGuard]
|
|
116
|
+
})
|
|
117
|
+
export class DashboardPage extends HTMLElement {
|
|
118
|
+
// ...
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Context for Global State
|
|
123
|
+
|
|
124
|
+
Access shared state via context:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import type { Principal } from './types/auth';
|
|
128
|
+
|
|
129
|
+
@context()
|
|
130
|
+
handleContext(ctx: Context) {
|
|
131
|
+
const principal = ctx.application.principal as Principal | undefined;
|
|
132
|
+
this.user = principal?.user;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Customization
|
|
137
|
+
|
|
138
|
+
### Replace Mock API
|
|
139
|
+
|
|
140
|
+
Update `src/services/auth.ts` to call your real API:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
export async function login(credentials: LoginCredentials) {
|
|
144
|
+
const response = await fetch('https://your-api.com/auth/login', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify(credentials)
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const data = await response.json();
|
|
151
|
+
setToken(data.token);
|
|
152
|
+
setUser(data.user);
|
|
153
|
+
return data;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Enable Real WebSocket
|
|
158
|
+
|
|
159
|
+
Update `src/daemons/notifications.ts`:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Replace startMockNotifications() with:
|
|
163
|
+
this.connect();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Configure Environment
|
|
167
|
+
|
|
168
|
+
Create `.env` file:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
VITE_API_URL=https://your-api.com
|
|
172
|
+
VITE_WS_URL=wss://your-ws.com
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Update Icons
|
|
176
|
+
|
|
177
|
+
Replace placeholder icons in `public/icons/`:
|
|
178
|
+
- `icon-192.png` (192x192)
|
|
179
|
+
- `icon-512.png` (512x512)
|
|
180
|
+
|
|
181
|
+
## Learn More
|
|
182
|
+
|
|
183
|
+
- [Snice Documentation](https://sniceio.github.io/snice)
|
|
184
|
+
- [Vite PWA Plugin](https://vite-pwa-org.netlify.app/)
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="description" content="{{projectName}} - PWA built with Snice" />
|
|
7
|
+
<meta name="theme-color" content="#6366f1" />
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
9
|
+
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
10
|
+
<title>{{projectName}}</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div id="app"></div>
|
|
14
|
+
<script type="module" src="/src/main.ts"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"type-check": "tsc --noEmit",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"test:coverage": "vitest run --coverage"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"snice": "^3.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^20.0.0",
|
|
20
|
+
"@vite-pwa/assets-generator": "^0.2.4",
|
|
21
|
+
"@vitest/coverage-v8": "^1.0.4",
|
|
22
|
+
"@vitest/ui": "^1.0.4",
|
|
23
|
+
"happy-dom": "^12.10.3",
|
|
24
|
+
"terser": "^5.24.0",
|
|
25
|
+
"typescript": "^5.3.3",
|
|
26
|
+
"unplugin-swc": "^1.5.7",
|
|
27
|
+
"vite": "^5.0.10",
|
|
28
|
+
"vite-plugin-pwa": "^0.17.4",
|
|
29
|
+
"vitest": "^1.0.4",
|
|
30
|
+
"workbox-window": "^7.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Notification } from '../types/notifications';
|
|
2
|
+
|
|
3
|
+
type NotificationHandler = (notification: Notification) => void;
|
|
4
|
+
|
|
5
|
+
export class NotificationsDaemon {
|
|
6
|
+
private ws: WebSocket | null = null;
|
|
7
|
+
private handlers: Set<NotificationHandler> = new Set();
|
|
8
|
+
private reconnectInterval: number = 5000;
|
|
9
|
+
private reconnectTimer: number | null = null;
|
|
10
|
+
private mockInterval: number | null = null;
|
|
11
|
+
private url: string;
|
|
12
|
+
private isStarted: boolean = false;
|
|
13
|
+
|
|
14
|
+
constructor(url: string) {
|
|
15
|
+
this.url = url;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
start(): void {
|
|
19
|
+
if (this.isStarted) return;
|
|
20
|
+
this.isStarted = true;
|
|
21
|
+
|
|
22
|
+
// For demo purposes, use mock notifications instead of real WebSocket
|
|
23
|
+
// In production, uncomment the connect() line and remove startMockNotifications()
|
|
24
|
+
// this.connect();
|
|
25
|
+
this.startMockNotifications();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop(): void {
|
|
29
|
+
if (!this.isStarted) return;
|
|
30
|
+
this.isStarted = false;
|
|
31
|
+
|
|
32
|
+
this.stopMockNotifications();
|
|
33
|
+
|
|
34
|
+
if (this.reconnectTimer !== null) {
|
|
35
|
+
clearTimeout(this.reconnectTimer);
|
|
36
|
+
this.reconnectTimer = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (this.ws) {
|
|
40
|
+
this.ws.close();
|
|
41
|
+
this.ws = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
dispose(): void {
|
|
46
|
+
this.stop();
|
|
47
|
+
this.handlers.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
subscribe(handler: NotificationHandler): () => void {
|
|
51
|
+
this.handlers.add(handler);
|
|
52
|
+
return () => this.handlers.delete(handler);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private connect(): void {
|
|
56
|
+
try {
|
|
57
|
+
this.ws = new WebSocket(this.url);
|
|
58
|
+
|
|
59
|
+
this.ws.onopen = () => {
|
|
60
|
+
console.log('WebSocket connected');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
this.ws.onmessage = (event) => {
|
|
64
|
+
try {
|
|
65
|
+
const notification: Notification = JSON.parse(event.data);
|
|
66
|
+
this.notify(notification);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('Failed to parse notification:', err);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
this.ws.onerror = (error) => {
|
|
73
|
+
console.error('WebSocket error:', error);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.ws.onclose = () => {
|
|
77
|
+
console.log('WebSocket disconnected');
|
|
78
|
+
if (this.isStarted) {
|
|
79
|
+
this.scheduleReconnect();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('Failed to create WebSocket:', err);
|
|
84
|
+
if (this.isStarted) {
|
|
85
|
+
this.scheduleReconnect();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private scheduleReconnect(): void {
|
|
91
|
+
if (this.reconnectTimer !== null) return;
|
|
92
|
+
|
|
93
|
+
this.reconnectTimer = window.setTimeout(() => {
|
|
94
|
+
this.reconnectTimer = null;
|
|
95
|
+
this.connect();
|
|
96
|
+
}, this.reconnectInterval);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private notify(notification: Notification): void {
|
|
100
|
+
this.handlers.forEach(handler => {
|
|
101
|
+
try {
|
|
102
|
+
handler(notification);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('Notification handler error:', err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Mock notifications for demo
|
|
110
|
+
private startMockNotifications(): void {
|
|
111
|
+
const messages = [
|
|
112
|
+
{ title: 'Welcome!', message: 'Thanks for checking out the PWA template', type: 'info' as const },
|
|
113
|
+
{ title: 'New Feature', message: 'Check out the notifications page', type: 'success' as const },
|
|
114
|
+
{ title: 'System Update', message: 'A new version is available', type: 'info' as const },
|
|
115
|
+
{ title: 'Reminder', message: 'Don\'t forget to star the repo!', type: 'warning' as const }
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
let index = 0;
|
|
119
|
+
this.mockInterval = window.setInterval(() => {
|
|
120
|
+
const notification: Notification = {
|
|
121
|
+
id: `mock-${Date.now()}`,
|
|
122
|
+
...messages[index % messages.length],
|
|
123
|
+
timestamp: new Date().toISOString()
|
|
124
|
+
};
|
|
125
|
+
this.notify(notification);
|
|
126
|
+
index++;
|
|
127
|
+
}, 10000); // Send notification every 10 seconds
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private stopMockNotifications(): void {
|
|
131
|
+
if (this.mockInterval !== null) {
|
|
132
|
+
clearInterval(this.mockInterval);
|
|
133
|
+
this.mockInterval = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Singleton instance
|
|
139
|
+
let instance: NotificationsDaemon | null = null;
|
|
140
|
+
|
|
141
|
+
export function getNotificationsDaemon(): NotificationsDaemon {
|
|
142
|
+
if (!instance) {
|
|
143
|
+
// In production, use actual WebSocket URL from env
|
|
144
|
+
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8080';
|
|
145
|
+
instance = new NotificationsDaemon(wsUrl);
|
|
146
|
+
}
|
|
147
|
+
return instance;
|
|
148
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ContextAwareFetcher } from 'snice';
|
|
2
|
+
import { authMiddleware } from './middleware/auth';
|
|
3
|
+
import { errorMiddleware } from './middleware/error';
|
|
4
|
+
import { createRetryMiddleware } from './middleware/retry';
|
|
5
|
+
|
|
6
|
+
const fetcher = new ContextAwareFetcher();
|
|
7
|
+
|
|
8
|
+
// Add request middleware (runs before fetch)
|
|
9
|
+
fetcher.use('request', authMiddleware);
|
|
10
|
+
fetcher.use('request', createRetryMiddleware());
|
|
11
|
+
|
|
12
|
+
// Add response middleware (runs after fetch)
|
|
13
|
+
fetcher.use('response', errorMiddleware);
|
|
14
|
+
|
|
15
|
+
export { fetcher };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AppContext } from 'snice';
|
|
2
|
+
import type { Principal } from '../types/auth';
|
|
3
|
+
|
|
4
|
+
export function authGuard(appContext: AppContext): boolean {
|
|
5
|
+
const principal = appContext.principal as Principal | undefined;
|
|
6
|
+
|
|
7
|
+
if (!principal?.isAuthenticated) {
|
|
8
|
+
window.location.href = '#/login';
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { initialize } from './router';
|
|
2
|
+
import './styles/global.css';
|
|
3
|
+
|
|
4
|
+
// Import snice layout and components
|
|
5
|
+
import 'snice/components/layout/snice-layout';
|
|
6
|
+
import 'snice/components/button/snice-button';
|
|
7
|
+
import 'snice/components/card/snice-card';
|
|
8
|
+
import 'snice/components/input/snice-input';
|
|
9
|
+
import 'snice/components/alert/snice-alert';
|
|
10
|
+
import 'snice/components/avatar/snice-avatar';
|
|
11
|
+
import 'snice/components/empty-state/snice-empty-state';
|
|
12
|
+
import 'snice/components/spinner/snice-spinner';
|
|
13
|
+
|
|
14
|
+
// Import pages
|
|
15
|
+
import './pages/login';
|
|
16
|
+
import './pages/dashboard';
|
|
17
|
+
import './pages/profile';
|
|
18
|
+
import './pages/notifications';
|
|
19
|
+
|
|
20
|
+
// Import and start daemons
|
|
21
|
+
import { getNotificationsDaemon } from './daemons/notifications';
|
|
22
|
+
|
|
23
|
+
// Start notifications daemon
|
|
24
|
+
const notificationsDaemon = getNotificationsDaemon();
|
|
25
|
+
notificationsDaemon.start();
|
|
26
|
+
|
|
27
|
+
// Initialize router
|
|
28
|
+
initialize();
|
|
29
|
+
|
|
30
|
+
// Register service worker (PWA)
|
|
31
|
+
if ('serviceWorker' in navigator) {
|
|
32
|
+
window.addEventListener('load', () => {
|
|
33
|
+
navigator.serviceWorker.register('/sw.js').then(
|
|
34
|
+
(registration) => {
|
|
35
|
+
console.log('Service Worker registered:', registration);
|
|
36
|
+
},
|
|
37
|
+
(error) => {
|
|
38
|
+
console.error('Service Worker registration failed:', error);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
|
+
import { getToken } from '../services/storage';
|
|
3
|
+
|
|
4
|
+
export async function authMiddleware(
|
|
5
|
+
this: Context,
|
|
6
|
+
request: Request,
|
|
7
|
+
next: () => Promise<Response>
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
const token = getToken();
|
|
10
|
+
|
|
11
|
+
if (token) {
|
|
12
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
|
+
import type { Principal } from '../types/auth';
|
|
3
|
+
import { clearToken } from '../services/storage';
|
|
4
|
+
|
|
5
|
+
export async function errorMiddleware(
|
|
6
|
+
this: Context,
|
|
7
|
+
response: Response,
|
|
8
|
+
next: () => Promise<Response>
|
|
9
|
+
): Promise<Response> {
|
|
10
|
+
// Handle 401 unauthorized - token expired or invalid
|
|
11
|
+
if (response.status === 401) {
|
|
12
|
+
clearToken();
|
|
13
|
+
|
|
14
|
+
// Update context to reflect logged out state
|
|
15
|
+
if (this.application.principal) {
|
|
16
|
+
const principal = this.application.principal as Principal;
|
|
17
|
+
principal.user = null;
|
|
18
|
+
principal.isAuthenticated = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
window.location.href = '#/login';
|
|
22
|
+
throw new Error('Unauthorized - redirecting to login');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Handle other errors
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const contentType = response.headers.get('content-type');
|
|
28
|
+
if (contentType && contentType.includes('application/json')) {
|
|
29
|
+
const error = await response.json();
|
|
30
|
+
throw new Error(error.message || `Request failed with status ${response.status}`);
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Request failed with status ${response.status}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return next();
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
|
+
|
|
3
|
+
export function createRetryMiddleware(retries = 3, delay = 1000) {
|
|
4
|
+
return async function retryMiddleware(
|
|
5
|
+
this: Context,
|
|
6
|
+
_request: Request,
|
|
7
|
+
next: () => Promise<Response>
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
let lastError: Error;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < retries; i++) {
|
|
12
|
+
try {
|
|
13
|
+
return await next();
|
|
14
|
+
} catch (err) {
|
|
15
|
+
lastError = err as Error;
|
|
16
|
+
|
|
17
|
+
// Don't retry on certain errors
|
|
18
|
+
if (err instanceof Error && err.message.includes('Unauthorized')) {
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Wait before retrying (exponential backoff)
|
|
23
|
+
if (i < retries - 1) {
|
|
24
|
+
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw lastError!;
|
|
30
|
+
};
|
|
31
|
+
}
|