@webstir-io/webstir 0.1.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 +69 -0
- package/assets/features/client_nav/client_nav.ts +469 -0
- package/assets/features/content_nav/content_nav.css +170 -0
- package/assets/features/content_nav/content_nav.ts +358 -0
- package/assets/features/router/router-types.ts +6 -0
- package/assets/features/router/router.ts +118 -0
- package/assets/features/search/search.css +204 -0
- package/assets/features/search/search.ts +627 -0
- package/assets/templates/api/src/backend/index.ts +13 -0
- package/assets/templates/api/src/backend/tsconfig.json +15 -0
- package/assets/templates/api/src/shared/router-types.ts +23 -0
- package/assets/templates/api/src/shared/tsconfig.json +10 -0
- package/assets/templates/api/src/shared/types/index.ts +4 -0
- package/assets/templates/full/src/backend/index.ts +13 -0
- package/assets/templates/full/src/backend/tsconfig.json +15 -0
- package/assets/templates/full/src/frontend/app/app.css +65 -0
- package/assets/templates/full/src/frontend/app/app.html +13 -0
- package/assets/templates/full/src/frontend/app/app.ts +188 -0
- package/assets/templates/full/src/frontend/app/error.ts +127 -0
- package/assets/templates/full/src/frontend/app/hmr.js +355 -0
- package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
- package/assets/templates/full/src/frontend/app/refresh.js +114 -0
- package/assets/templates/full/src/frontend/app/router.ts +126 -0
- package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
- package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
- package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
- package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
- package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
- package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
- package/assets/templates/full/src/frontend/tsconfig.json +20 -0
- package/assets/templates/full/src/shared/router-types.ts +23 -0
- package/assets/templates/full/src/shared/tsconfig.json +10 -0
- package/assets/templates/full/src/shared/types/index.ts +4 -0
- package/assets/templates/shared/Errors.404.html +23 -0
- package/assets/templates/shared/Errors.500.html +23 -0
- package/assets/templates/shared/Errors.default.html +23 -0
- package/assets/templates/shared/types/global.d.ts +32 -0
- package/assets/templates/shared/types.global.d.ts +32 -0
- package/assets/templates/spa/src/frontend/app/app.css +65 -0
- package/assets/templates/spa/src/frontend/app/app.html +13 -0
- package/assets/templates/spa/src/frontend/app/app.ts +188 -0
- package/assets/templates/spa/src/frontend/app/error.ts +127 -0
- package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
- package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
- package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
- package/assets/templates/spa/src/frontend/app/router.ts +126 -0
- package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
- package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
- package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
- package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
- package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
- package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
- package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
- package/assets/templates/spa/src/shared/router-types.ts +23 -0
- package/assets/templates/spa/src/shared/tsconfig.json +10 -0
- package/assets/templates/spa/src/shared/types/index.ts +4 -0
- package/assets/templates/ssg/src/frontend/app/app.css +12 -0
- package/assets/templates/ssg/src/frontend/app/app.html +43 -0
- package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
- package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
- package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
- package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
- package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
- package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
- package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
- package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
- package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
- package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
- package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
- package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
- package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
- package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
- package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
- package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
- package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
- package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
- package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
- package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
- package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
- package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
- package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
- package/package.json +41 -0
- package/scripts/pack-standalone.mjs +127 -0
- package/scripts/sync-assets.mjs +87 -0
- package/src/add-backend.ts +164 -0
- package/src/add.ts +112 -0
- package/src/api-watch.ts +84 -0
- package/src/backend-inspect.ts +45 -0
- package/src/backend-runtime.ts +286 -0
- package/src/build-plan.ts +12 -0
- package/src/build.ts +10 -0
- package/src/cli.ts +569 -0
- package/src/compile-tests.ts +61 -0
- package/src/dev-server.ts +393 -0
- package/src/enable-assets.ts +196 -0
- package/src/enable.ts +477 -0
- package/src/execute.ts +85 -0
- package/src/format.ts +254 -0
- package/src/frontend-watch.ts +145 -0
- package/src/full-watch.ts +80 -0
- package/src/index.ts +20 -0
- package/src/init-assets.ts +96 -0
- package/src/init.ts +339 -0
- package/src/paths.ts +26 -0
- package/src/providers.ts +88 -0
- package/src/publish.ts +8 -0
- package/src/refresh.ts +56 -0
- package/src/repair.ts +414 -0
- package/src/runtime.ts +48 -0
- package/src/smoke.ts +161 -0
- package/src/stop-signal.ts +26 -0
- package/src/test.ts +215 -0
- package/src/types.ts +29 -0
- package/src/watch-daemon-client.ts +171 -0
- package/src/watch-events.ts +195 -0
- package/src/watch.ts +66 -0
- package/src/workspace-watcher.ts +251 -0
- package/src/workspace.ts +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Basic Home page test: verifies merged HTML has expected parts
|
|
2
|
+
// The default provider is configured via WEBSTIR_TESTING_PROVIDER or webstir.providers.json.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { test, assert } from '@webstir-io/webstir-testing';
|
|
7
|
+
|
|
8
|
+
// Node runs this test as ESM; derive the directory from the module URL.
|
|
9
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
// Built HTML is at build/frontend/pages/home/index.html relative to the compiled test output.
|
|
12
|
+
test('home page has expected parts', () => {
|
|
13
|
+
const htmlPath = resolve(currentDir, '..', 'index.html');
|
|
14
|
+
const html = readFileSync(htmlPath, 'utf8');
|
|
15
|
+
|
|
16
|
+
assert.isTrue(html.includes('<title>Home</title>'), 'Missing <title>Home</title>');
|
|
17
|
+
assert.isTrue(html.includes('<link rel="stylesheet" href="index.css"'), 'Missing CSS link to index.css');
|
|
18
|
+
assert.isTrue(html.includes('<script type="module" src="index.js"'), 'Missing module script to index.js');
|
|
19
|
+
assert.isTrue(html.includes('<main'), 'Missing <main> container');
|
|
20
|
+
assert.isTrue(html.includes('Home'), 'Missing Home content');
|
|
21
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../base.tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["ES2022", "DOM"],
|
|
6
|
+
"outDir": "../../build/frontend",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"baseUrl": ".",
|
|
9
|
+
"incremental": true,
|
|
10
|
+
"tsBuildInfoFile": "../../build/frontend/.tsbuildinfo",
|
|
11
|
+
"paths": {
|
|
12
|
+
"@shared/*": ["../shared/*"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"include": ["**/*.ts", "../../types/**/*.d.ts"],
|
|
16
|
+
"exclude": ["node_modules", "../../build", "../../dist"],
|
|
17
|
+
"references": [
|
|
18
|
+
{ "path": "../shared" }
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RouteHandler {
|
|
2
|
+
onEnter?: (params: RouteParams) => void | Promise<void>;
|
|
3
|
+
onLeave?: () => void | Promise<void>;
|
|
4
|
+
onUpdate?: (params: RouteParams) => void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RouteParams {
|
|
8
|
+
[key: string]: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RoutingMetadata {
|
|
12
|
+
pages: {
|
|
13
|
+
[pageName: string]: PageRouteInfo;
|
|
14
|
+
};
|
|
15
|
+
hasSpaPages: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PageRouteInfo {
|
|
19
|
+
pageName: string;
|
|
20
|
+
route: string;
|
|
21
|
+
isSpaEnabled: boolean;
|
|
22
|
+
typeScriptPath: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Webstir CSS System (v0) */
|
|
2
|
+
@layer reset, tokens, base, layout, components, features, utilities, overrides;
|
|
3
|
+
|
|
4
|
+
@import "./styles/reset.css";
|
|
5
|
+
@import "./styles/tokens.css";
|
|
6
|
+
@import "./styles/base.css";
|
|
7
|
+
@import "./styles/layout.css";
|
|
8
|
+
@import "./styles/components/markdown.css";
|
|
9
|
+
@import "./styles/components/header.css";
|
|
10
|
+
@import "./styles/components/drawer.css";
|
|
11
|
+
@import "./styles/components/buttons.css";
|
|
12
|
+
@import "./styles/utilities.css";
|
|
@@ -0,0 +1,43 @@
|
|
|
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="Starter description for your Webstir app. Update it to summarize this page for search results.">
|
|
7
|
+
<link rel="stylesheet" href="/app/app.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="app-header">
|
|
11
|
+
<div class="app-header__inner ws-container">
|
|
12
|
+
<a class="app-brand" href="/" aria-label="Webstir home">
|
|
13
|
+
Webstir
|
|
14
|
+
</a>
|
|
15
|
+
<div class="app-menu" data-app-menu>
|
|
16
|
+
<button class="app-menu__toggle ws-icon-button" type="button" aria-label="Menu" aria-controls="app-menu-nav" aria-expanded="false">
|
|
17
|
+
<span class="app-menu__icon app-menu__icon--open" aria-hidden="true">
|
|
18
|
+
<svg viewBox="0 0 20 20" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
19
|
+
<path d="M2 3h16M2 10h16M2 17h16"></path>
|
|
20
|
+
</svg>
|
|
21
|
+
</span>
|
|
22
|
+
<span class="app-menu__icon app-menu__icon--close" aria-hidden="true">
|
|
23
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
24
|
+
<path d="M18 6L6 18"></path>
|
|
25
|
+
<path d="M6 6l12 12"></path>
|
|
26
|
+
</svg>
|
|
27
|
+
</span>
|
|
28
|
+
<span class="sr-only">Menu</span>
|
|
29
|
+
</button>
|
|
30
|
+
<nav id="app-menu-nav" class="app-nav" aria-label="Primary">
|
|
31
|
+
<a href="/docs/">Docs</a>
|
|
32
|
+
<a href="/about">About</a>
|
|
33
|
+
</nav>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</header>
|
|
37
|
+
<div class="ws-drawer-backdrop" data-drawer="menu" data-drawer-close aria-hidden="true"></div>
|
|
38
|
+
<main></main>
|
|
39
|
+
<script type="module" src="/app/app.js"></script>
|
|
40
|
+
<script type="module" src="/hmr.js"></script>
|
|
41
|
+
<script src="/refresh.js" async></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import './scripts/components/menu.js';
|
|
2
|
+
|
|
3
|
+
// Global app initialization
|
|
4
|
+
|
|
5
|
+
type HotAsset = {
|
|
6
|
+
type: 'js' | 'css';
|
|
7
|
+
url: string;
|
|
8
|
+
relativePath: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type HotModuleContext = {
|
|
12
|
+
changedFile: string | null;
|
|
13
|
+
modules: ReadonlyArray<HotAsset>;
|
|
14
|
+
styles: ReadonlyArray<HotAsset>;
|
|
15
|
+
cacheBuster: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
asset?: HotAsset;
|
|
18
|
+
previousExports?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type HotModuleHandlers = {
|
|
22
|
+
accept?: (moduleExports: unknown, context: HotModuleContext) => boolean | Promise<boolean>;
|
|
23
|
+
dispose?: (context: HotModuleContext) => void | Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type HotModuleRecord = HotModuleHandlers & {
|
|
27
|
+
currentExports?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
declare global {
|
|
31
|
+
interface Window {
|
|
32
|
+
__webstirEventSource?: EventSource;
|
|
33
|
+
__webstirSetDevStatus?: (status: string, message?: string) => void;
|
|
34
|
+
__webstirOnHmrFallback?: (info: { reason?: string; payload?: unknown; details?: unknown }) => void;
|
|
35
|
+
__webstirRegisterHotModule?: (moduleId: string, handlers: HotModuleHandlers) => void;
|
|
36
|
+
__webstirDispose?: (asset: HotAsset | undefined, context: HotModuleContext) => boolean | Promise<boolean>;
|
|
37
|
+
__webstirAccept?: (moduleExports: unknown, context: HotModuleContext) => boolean | Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hotModuleRegistry = new Map<string, HotModuleRecord>();
|
|
42
|
+
|
|
43
|
+
function ensureRecord(moduleId: string): HotModuleRecord {
|
|
44
|
+
const existing = hotModuleRegistry.get(moduleId);
|
|
45
|
+
if (existing) {
|
|
46
|
+
return existing;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const created: HotModuleRecord = {};
|
|
50
|
+
hotModuleRegistry.set(moduleId, created);
|
|
51
|
+
return created;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeModuleId(candidate?: string | null): string | null {
|
|
55
|
+
if (!candidate) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(candidate, window.location.origin);
|
|
61
|
+
return url.pathname;
|
|
62
|
+
} catch {
|
|
63
|
+
const index = candidate.indexOf('?');
|
|
64
|
+
return index === -1 ? candidate : candidate.slice(0, index);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPromise<T = unknown>(value: unknown): value is Promise<T> {
|
|
69
|
+
return !!value && typeof (value as PromiseLike<T>).then === 'function';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function withHistoryContext(context: HotModuleContext, record: HotModuleRecord): HotModuleContext {
|
|
73
|
+
if (!record.currentExports) {
|
|
74
|
+
return context;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...context,
|
|
79
|
+
previousExports: record.currentExports
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function evaluateHandlerResult(result: unknown): Promise<boolean> {
|
|
84
|
+
if (isPromise(result)) {
|
|
85
|
+
const resolved = await result;
|
|
86
|
+
return resolved !== false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result !== false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Lazy-load error handler on first error
|
|
93
|
+
let errorHandlerLoaded = false;
|
|
94
|
+
|
|
95
|
+
async function loadErrorHandler() {
|
|
96
|
+
if (errorHandlerLoaded) return;
|
|
97
|
+
errorHandlerLoaded = true;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const { install } = await import('./error.js');
|
|
101
|
+
install();
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error('Failed to load error handler:', e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerHotModule(moduleId: string, handlers: HotModuleHandlers): void {
|
|
108
|
+
const normalized = normalizeModuleId(moduleId);
|
|
109
|
+
if (!normalized) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const record = ensureRecord(normalized);
|
|
114
|
+
record.accept = handlers.accept;
|
|
115
|
+
record.dispose = handlers.dispose;
|
|
116
|
+
hotModuleRegistry.set(normalized, record);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
window.__webstirRegisterHotModule = registerHotModule;
|
|
120
|
+
|
|
121
|
+
window.__webstirDispose = async (asset, context) => {
|
|
122
|
+
const moduleId = normalizeModuleId(asset?.url ?? asset?.relativePath);
|
|
123
|
+
if (!moduleId) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const record = hotModuleRegistry.get(moduleId);
|
|
128
|
+
if (!record) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!record.dispose) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const contextWithHistory = withHistoryContext(context, record);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = record.dispose(contextWithHistory);
|
|
140
|
+
if (isPromise(result)) {
|
|
141
|
+
await result;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error(`[webstir-hmr] Dispose handler failed for ${moduleId}.`, error);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
window.__webstirAccept = async (moduleExports, context) => {
|
|
151
|
+
const moduleId = normalizeModuleId(context?.asset?.url ?? context?.asset?.relativePath);
|
|
152
|
+
if (!moduleId) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const record = ensureRecord(moduleId);
|
|
157
|
+
const contextWithHistory = withHistoryContext(context, record);
|
|
158
|
+
|
|
159
|
+
let accepted = true;
|
|
160
|
+
|
|
161
|
+
if (record.accept) {
|
|
162
|
+
try {
|
|
163
|
+
accepted = await evaluateHandlerResult(record.accept(moduleExports, contextWithHistory));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(`[webstir-hmr] Accept handler failed for ${moduleId}.`, error);
|
|
166
|
+
accepted = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (accepted) {
|
|
171
|
+
record.currentExports = moduleExports;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
hotModuleRegistry.set(moduleId, record);
|
|
175
|
+
return accepted;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Set up error listeners that will dynamically import the error handler
|
|
179
|
+
window.addEventListener('error', async () => {
|
|
180
|
+
await loadErrorHandler();
|
|
181
|
+
// The installed handler will catch subsequent errors
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
window.addEventListener('unhandledrejection', async () => {
|
|
185
|
+
await loadErrorHandler();
|
|
186
|
+
// The installed handler will catch subsequent rejections
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Export for use by pages if needed
|
|
190
|
+
export { loadErrorHandler };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Global client-side error reporter (TypeScript)
|
|
2
|
+
let lastSentAt = 0;
|
|
3
|
+
let sentCount = 0;
|
|
4
|
+
const MAX_PER_SESSION = 20;
|
|
5
|
+
const MIN_INTERVAL_MS = 1000;
|
|
6
|
+
const DEDUPE_WINDOW_MS = 60_000; // 60s
|
|
7
|
+
const recent = new Map<string, number>(); // fingerprint -> timestamp
|
|
8
|
+
|
|
9
|
+
function cid(): string {
|
|
10
|
+
const w = window as any;
|
|
11
|
+
if (!w.__WEBSTIR_CID__) {
|
|
12
|
+
w.__WEBSTIR_CID__ = 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
13
|
+
}
|
|
14
|
+
return String(w.__WEBSTIR_CID__);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Payload = {
|
|
18
|
+
type: 'error' | 'unhandledrejection';
|
|
19
|
+
message: string;
|
|
20
|
+
stack: string;
|
|
21
|
+
filename: string;
|
|
22
|
+
lineno: number;
|
|
23
|
+
colno: number;
|
|
24
|
+
pageUrl: string;
|
|
25
|
+
userAgent: string;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
correlationId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function toPayload(e: ErrorEvent | PromiseRejectionEvent): Payload {
|
|
31
|
+
const isRejection = !!e && e.type === 'unhandledrejection';
|
|
32
|
+
const reason: any = isRejection ? ((e as PromiseRejectionEvent).reason || {}) : {};
|
|
33
|
+
const err: any = (e as ErrorEvent)?.error || reason || {};
|
|
34
|
+
const message = (e as ErrorEvent)?.message || reason.message || err?.message || 'Unknown error';
|
|
35
|
+
const stack = String(err?.stack || reason.stack || '');
|
|
36
|
+
const filename = String((e as ErrorEvent)?.filename || '');
|
|
37
|
+
const lineno = Number((e as ErrorEvent)?.lineno || 0);
|
|
38
|
+
const colno = Number((e as ErrorEvent)?.colno || 0);
|
|
39
|
+
return {
|
|
40
|
+
type: isRejection ? 'unhandledrejection' : 'error',
|
|
41
|
+
message: String(message || ''),
|
|
42
|
+
stack,
|
|
43
|
+
filename,
|
|
44
|
+
lineno,
|
|
45
|
+
colno,
|
|
46
|
+
pageUrl: String(location.href),
|
|
47
|
+
userAgent: String(navigator.userAgent || ''),
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
correlationId: cid(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Simple 32-bit FNV-1a
|
|
54
|
+
function hash(str: string): string {
|
|
55
|
+
let h = 2166136261 >>> 0;
|
|
56
|
+
for (let i = 0; i < str.length; i++) {
|
|
57
|
+
h ^= str.charCodeAt(i);
|
|
58
|
+
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
|
|
59
|
+
}
|
|
60
|
+
return h.toString(36);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fingerprint(p: Payload): string {
|
|
64
|
+
return [
|
|
65
|
+
p.type || '',
|
|
66
|
+
p.message || '',
|
|
67
|
+
`${p.filename || ''}:${p.lineno || 0}:${p.colno || 0}`,
|
|
68
|
+
hash(p.stack || ''),
|
|
69
|
+
].join('|');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shouldSend(p: Payload): boolean {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (now - lastSentAt < MIN_INTERVAL_MS) return false;
|
|
75
|
+
if (sentCount >= MAX_PER_SESSION) return false;
|
|
76
|
+
|
|
77
|
+
const fp = fingerprint(p);
|
|
78
|
+
const last = recent.get(fp) || 0;
|
|
79
|
+
|
|
80
|
+
// prune old entries opportunistically
|
|
81
|
+
if (recent.size > 100) {
|
|
82
|
+
recent.forEach((ts, k) => {
|
|
83
|
+
if (now - ts > DEDUPE_WINDOW_MS) recent.delete(k);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (now - last < DEDUPE_WINDOW_MS) return false;
|
|
88
|
+
recent.set(fp, now);
|
|
89
|
+
lastSentAt = now;
|
|
90
|
+
sentCount++;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function report(e: ErrorEvent | PromiseRejectionEvent): void {
|
|
95
|
+
try {
|
|
96
|
+
const p = toPayload(e);
|
|
97
|
+
if (!shouldSend(p)) return;
|
|
98
|
+
const payload = JSON.stringify(p);
|
|
99
|
+
if (navigator.sendBeacon) {
|
|
100
|
+
const blob = new Blob([payload], { type: 'application/json' });
|
|
101
|
+
navigator.sendBeacon('/client-errors', blob);
|
|
102
|
+
} else {
|
|
103
|
+
fetch('/client-errors', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'Content-Type': 'application/json', 'X-Correlation-ID': cid() },
|
|
106
|
+
body: payload,
|
|
107
|
+
keepalive: true,
|
|
108
|
+
}).catch(() => { /* ignore */ });
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function install(): void {
|
|
116
|
+
const w = window as any;
|
|
117
|
+
if (w.__WEBSTIR_ERROR_HANDLER_INSTALLED__) return;
|
|
118
|
+
w.__WEBSTIR_ERROR_HANDLER_INSTALLED__ = true;
|
|
119
|
+
|
|
120
|
+
window.addEventListener('error', (e) => {
|
|
121
|
+
try { report(e); } catch { /* ignore */ }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
125
|
+
try { report(e); } catch { /* ignore */ }
|
|
126
|
+
});
|
|
127
|
+
}
|