@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.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. 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,10 @@
1
+ {
2
+ "extends": "../../base.tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../build/shared",
5
+ "rootDir": ".",
6
+ "composite": true,
7
+ "declaration": true
8
+ },
9
+ "include": ["**/*.ts", "../../types/**/*.d.ts"]
10
+ }
@@ -0,0 +1,4 @@
1
+ export interface ApiResponse<T> {
2
+ data?: T;
3
+ error?: string;
4
+ }
@@ -0,0 +1,23 @@
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" />
6
+ <title>Page Not Found</title>
7
+ <meta name="robots" content="noindex,nofollow,noarchive,nosnippet" />
8
+ <style>
9
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 2rem; color: #222; background: #fafafa }
10
+ main { max-width: 720px; margin: 10vh auto; text-align: center }
11
+ h1 { font-size: 1.75rem; margin: 0 0 0.5rem }
12
+ p { color: #555; margin: 0.25rem 0 1rem }
13
+ code { background: #eee; padding: 0.125rem 0.25rem; border-radius: 4px; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <main>
18
+ <h1>Page Not Found</h1>
19
+ <p>The page you requested could not be found.</p>
20
+ <p><small>Status code: <code>404</code></small></p>
21
+ </main>
22
+ </body>
23
+ </html>
@@ -0,0 +1,23 @@
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" />
6
+ <title>Unexpected Error</title>
7
+ <meta name="robots" content="noindex,nofollow,noarchive,nosnippet" />
8
+ <style>
9
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 2rem; color: #222; background: #fafafa }
10
+ main { max-width: 720px; margin: 10vh auto; text-align: center }
11
+ h1 { font-size: 1.75rem; margin: 0 0 0.5rem }
12
+ p { color: #555; margin: 0.25rem 0 1rem }
13
+ code { background: #eee; padding: 0.125rem 0.25rem; border-radius: 4px; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <main>
18
+ <h1>Something went wrong</h1>
19
+ <p>An unexpected error occurred. Please try again later.</p>
20
+ <p><small>Status code: <code>500</code></small></p>
21
+ </main>
22
+ </body>
23
+ </html>
@@ -0,0 +1,23 @@
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" />
6
+ <title>{{TITLE}}</title>
7
+ <style>
8
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 2rem; color: #222; background: #fafafa }
9
+ main { max-width: 720px; margin: 10vh auto; text-align: center }
10
+ h1 { font-size: 1.75rem; margin: 0 0 0.5rem }
11
+ p { color: #555; margin: 0.25rem 0 1rem }
12
+ code { background: #eee; padding: 0.125rem 0.25rem; border-radius: 4px; }
13
+ </style>
14
+ <meta name="robots" content="noindex,nofollow,noarchive,nosnippet" />
15
+ </head>
16
+ <body>
17
+ <main>
18
+ <h1>{{TITLE}}</h1>
19
+ <p>{{MESSAGE}}</p>
20
+ <p><small>Status code: <code>{{STATUS}}</code></small></p>
21
+ </main>
22
+ </body>
23
+ </html>
@@ -0,0 +1,32 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>;
3
+ export default classes;
4
+ }
5
+
6
+ declare module '*.css' {
7
+ const css: string;
8
+ export default css;
9
+ }
10
+
11
+ declare module '@webstir-io/webstir-testing' {
12
+ export function test(description: string, callback: () => void | Promise<void>): void;
13
+ export const assert: {
14
+ isTrue(condition: unknown, message?: string): asserts condition;
15
+ equal<T>(actual: T, expected: T, message?: string): void;
16
+ fail(message?: string): never;
17
+ };
18
+ export function run(): Promise<void>;
19
+ }
20
+
21
+ declare module 'node:fs' {
22
+ export function readFileSync(path: string, options?: { encoding?: string } | string): string;
23
+ }
24
+
25
+ declare module 'node:path' {
26
+ export function dirname(path: string): string;
27
+ export function resolve(...segments: string[]): string;
28
+ }
29
+
30
+ declare module 'node:url' {
31
+ export function fileURLToPath(url: string): string;
32
+ }
@@ -0,0 +1,32 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>;
3
+ export default classes;
4
+ }
5
+
6
+ declare module '*.css' {
7
+ const css: string;
8
+ export default css;
9
+ }
10
+
11
+ declare module '@webstir-io/webstir-testing' {
12
+ export function test(description: string, callback: () => void | Promise<void>): void;
13
+ export const assert: {
14
+ isTrue(condition: unknown, message?: string): asserts condition;
15
+ equal<T>(actual: T, expected: T, message?: string): void;
16
+ fail(message?: string): never;
17
+ };
18
+ export function run(): Promise<void>;
19
+ }
20
+
21
+ declare module 'node:fs' {
22
+ export function readFileSync(path: string, options?: { encoding?: string } | string): string;
23
+ }
24
+
25
+ declare module 'node:path' {
26
+ export function dirname(path: string): string;
27
+ export function resolve(...segments: string[]): string;
28
+ }
29
+
30
+ declare module 'node:url' {
31
+ export function fileURLToPath(url: string): string;
32
+ }
@@ -0,0 +1,65 @@
1
+ /* Main App Stylesheet */
2
+ @import "./styles/reset.css";
3
+
4
+ /* Base body styles */
5
+ html, body {
6
+ margin: 0;
7
+ padding: 0;
8
+ height: 100%;
9
+ }
10
+
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
13
+ Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial,
14
+ sans-serif;
15
+ line-height: 1.5;
16
+ color: #1f2937;
17
+ background: #f8fafc;
18
+ }
19
+
20
+ main {
21
+ display: block;
22
+ }
23
+
24
+ /* Layout helpers */
25
+ .container {
26
+ width: 100%;
27
+ max-width: 1200px;
28
+ margin-left: auto;
29
+ margin-right: auto;
30
+ padding-left: 1rem;
31
+ padding-right: 1rem;
32
+ }
33
+
34
+ /* Utility classes */
35
+ .sr-only {
36
+ position: absolute;
37
+ width: 1px;
38
+ height: 1px;
39
+ padding: 0;
40
+ margin: -1px;
41
+ overflow: hidden;
42
+ clip: rect(0, 0, 0, 0);
43
+ white-space: nowrap; /* added line */
44
+ border: 0;
45
+ }
46
+
47
+ /* Basic typography */
48
+ h1, h2, h3, h4, h5, h6 {
49
+ color: #111827;
50
+ line-height: 1.25;
51
+ margin: 0 0 0.5rem 0;
52
+ }
53
+
54
+ p {
55
+ margin: 0 0 1rem 0;
56
+ }
57
+
58
+ a {
59
+ color: #2563eb;
60
+ text-decoration: none;
61
+ }
62
+
63
+ a:hover {
64
+ text-decoration: underline;
65
+ }
@@ -0,0 +1,13 @@
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
+ </head>
8
+ <body>
9
+ <main> </main>
10
+ <script type="module" src="/hmr.js"></script>
11
+ <script src="/refresh.js" async></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,188 @@
1
+ // Global app initialization
2
+
3
+ type HotAsset = {
4
+ type: 'js' | 'css';
5
+ url: string;
6
+ relativePath: string;
7
+ };
8
+
9
+ type HotModuleContext = {
10
+ changedFile: string | null;
11
+ modules: ReadonlyArray<HotAsset>;
12
+ styles: ReadonlyArray<HotAsset>;
13
+ cacheBuster: string;
14
+ timestamp: number;
15
+ asset?: HotAsset;
16
+ previousExports?: unknown;
17
+ };
18
+
19
+ type HotModuleHandlers = {
20
+ accept?: (moduleExports: unknown, context: HotModuleContext) => boolean | Promise<boolean>;
21
+ dispose?: (context: HotModuleContext) => void | Promise<void>;
22
+ };
23
+
24
+ type HotModuleRecord = HotModuleHandlers & {
25
+ currentExports?: unknown;
26
+ };
27
+
28
+ declare global {
29
+ interface Window {
30
+ __webstirEventSource?: EventSource;
31
+ __webstirSetDevStatus?: (status: string, message?: string) => void;
32
+ __webstirOnHmrFallback?: (info: { reason?: string; payload?: unknown; details?: unknown }) => void;
33
+ __webstirRegisterHotModule?: (moduleId: string, handlers: HotModuleHandlers) => void;
34
+ __webstirDispose?: (asset: HotAsset | undefined, context: HotModuleContext) => boolean | Promise<boolean>;
35
+ __webstirAccept?: (moduleExports: unknown, context: HotModuleContext) => boolean | Promise<boolean>;
36
+ }
37
+ }
38
+
39
+ const hotModuleRegistry = new Map<string, HotModuleRecord>();
40
+
41
+ function ensureRecord(moduleId: string): HotModuleRecord {
42
+ const existing = hotModuleRegistry.get(moduleId);
43
+ if (existing) {
44
+ return existing;
45
+ }
46
+
47
+ const created: HotModuleRecord = {};
48
+ hotModuleRegistry.set(moduleId, created);
49
+ return created;
50
+ }
51
+
52
+ function normalizeModuleId(candidate?: string | null): string | null {
53
+ if (!candidate) {
54
+ return null;
55
+ }
56
+
57
+ try {
58
+ const url = new URL(candidate, window.location.origin);
59
+ return url.pathname;
60
+ } catch {
61
+ const index = candidate.indexOf('?');
62
+ return index === -1 ? candidate : candidate.slice(0, index);
63
+ }
64
+ }
65
+
66
+ function isPromise<T = unknown>(value: unknown): value is Promise<T> {
67
+ return !!value && typeof (value as PromiseLike<T>).then === 'function';
68
+ }
69
+
70
+ function withHistoryContext(context: HotModuleContext, record: HotModuleRecord): HotModuleContext {
71
+ if (!record.currentExports) {
72
+ return context;
73
+ }
74
+
75
+ return {
76
+ ...context,
77
+ previousExports: record.currentExports
78
+ };
79
+ }
80
+
81
+ async function evaluateHandlerResult(result: unknown): Promise<boolean> {
82
+ if (isPromise(result)) {
83
+ const resolved = await result;
84
+ return resolved !== false;
85
+ }
86
+
87
+ return result !== false;
88
+ }
89
+
90
+ // Lazy-load error handler on first error
91
+ let errorHandlerLoaded = false;
92
+
93
+ async function loadErrorHandler() {
94
+ if (errorHandlerLoaded) return;
95
+ errorHandlerLoaded = true;
96
+
97
+ try {
98
+ const { install } = await import('./error');
99
+ install();
100
+ } catch (e) {
101
+ console.error('Failed to load error handler:', e);
102
+ }
103
+ }
104
+
105
+ export function registerHotModule(moduleId: string, handlers: HotModuleHandlers): void {
106
+ const normalized = normalizeModuleId(moduleId);
107
+ if (!normalized) {
108
+ return;
109
+ }
110
+
111
+ const record = ensureRecord(normalized);
112
+ record.accept = handlers.accept;
113
+ record.dispose = handlers.dispose;
114
+ hotModuleRegistry.set(normalized, record);
115
+ }
116
+
117
+ window.__webstirRegisterHotModule = registerHotModule;
118
+
119
+ window.__webstirDispose = async (asset, context) => {
120
+ const moduleId = normalizeModuleId(asset?.url ?? asset?.relativePath);
121
+ if (!moduleId) {
122
+ return true;
123
+ }
124
+
125
+ const record = hotModuleRegistry.get(moduleId);
126
+ if (!record) {
127
+ return true;
128
+ }
129
+
130
+ if (!record.dispose) {
131
+ return true;
132
+ }
133
+
134
+ const contextWithHistory = withHistoryContext(context, record);
135
+
136
+ try {
137
+ const result = record.dispose(contextWithHistory);
138
+ if (isPromise(result)) {
139
+ await result;
140
+ }
141
+ return true;
142
+ } catch (error) {
143
+ console.error(`[webstir-hmr] Dispose handler failed for ${moduleId}.`, error);
144
+ return false;
145
+ }
146
+ };
147
+
148
+ window.__webstirAccept = async (moduleExports, context) => {
149
+ const moduleId = normalizeModuleId(context?.asset?.url ?? context?.asset?.relativePath);
150
+ if (!moduleId) {
151
+ return true;
152
+ }
153
+
154
+ const record = ensureRecord(moduleId);
155
+ const contextWithHistory = withHistoryContext(context, record);
156
+
157
+ let accepted = true;
158
+
159
+ if (record.accept) {
160
+ try {
161
+ accepted = await evaluateHandlerResult(record.accept(moduleExports, contextWithHistory));
162
+ } catch (error) {
163
+ console.error(`[webstir-hmr] Accept handler failed for ${moduleId}.`, error);
164
+ accepted = false;
165
+ }
166
+ }
167
+
168
+ if (accepted) {
169
+ record.currentExports = moduleExports;
170
+ }
171
+
172
+ hotModuleRegistry.set(moduleId, record);
173
+ return accepted;
174
+ };
175
+
176
+ // Set up error listeners that will dynamically import the error handler
177
+ window.addEventListener('error', async () => {
178
+ await loadErrorHandler();
179
+ // The installed handler will catch subsequent errors
180
+ });
181
+
182
+ window.addEventListener('unhandledrejection', async () => {
183
+ await loadErrorHandler();
184
+ // The installed handler will catch subsequent rejections
185
+ });
186
+
187
+ // Export for use by pages if needed
188
+ 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
+ }