@sveltesentio/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/golusoris/sveltesentio/compare/core-v0.1.0...core-v0.2.0) (2026-06-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * **core:** no-direct-time ESLint rule + bundle-size gate in sentioPlugin ([f3e223b](https://github.com/golusoris/sveltesentio/commit/f3e223b24cafdfae9646315e9ab64ea0899fd297))
9
+
3
10
  ## [0.1.0](https://github.com/golusoris/sveltesentio/compare/core-v0.0.1...core-v0.1.0) (2026-06-14)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltesentio/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vite plugin, env schema, error types, id/clock utils — sveltesentio core",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,6 +37,10 @@
37
37
  "./vite": {
38
38
  "import": "./src/vite.ts",
39
39
  "types": "./src/vite.ts"
40
+ },
41
+ "./eslint": {
42
+ "import": "./src/eslint.ts",
43
+ "types": "./src/eslint.ts"
40
44
  }
41
45
  },
42
46
  "peerDependencies": {
@@ -54,15 +58,15 @@
54
58
  }
55
59
  },
56
60
  "devDependencies": {
57
- "@sveltejs/kit": "^2.0.0",
58
- "@types/node": "^24.0.0",
61
+ "@sveltejs/kit": "^2.65.1",
62
+ "@types/node": "^24.13.2",
59
63
  "esm-env": "^1.2.2",
60
64
  "openapi-fetch": "^0.17.0",
61
- "svelte": "^5.55.4",
65
+ "svelte": "^5.56.3",
62
66
  "typescript": "^6.0.3",
63
- "uuid": "^13.0.0",
64
- "vitest": "^4.1.4",
65
- "zod": "^4.3.6"
67
+ "uuid": "^14.0.0",
68
+ "vitest": "^4.1.8",
69
+ "zod": "^4.4.3"
66
70
  },
67
71
  "keywords": [
68
72
  "sveltesentio",
@@ -80,6 +84,6 @@
80
84
  "build": "tsc",
81
85
  "lint": "eslint src/",
82
86
  "typecheck": "tsc --noEmit",
83
- "test": "vitest run"
87
+ "test": "vitest run --coverage"
84
88
  }
85
89
  }
package/src/eslint.ts ADDED
@@ -0,0 +1,107 @@
1
+ import type { Rule } from 'eslint';
2
+
3
+ /**
4
+ * Flat-config ESLint plugin enforcing the core "no direct time reads" invariant
5
+ * (see AGENTS.md §Invariants + docs/principles.md §2.1): time must flow through
6
+ * the injected {@link Clock} (`useClock` / `getClock` from `@sveltesentio/core`)
7
+ * so it is deterministic and testable. Banned forms:
8
+ *
9
+ * - `Date.now()`
10
+ * - `new Date()` (zero-argument — `new Date(serverMs)` is explicit + allowed)
11
+ * - `performance.now()`
12
+ *
13
+ * Register in a flat `eslint.config.js`:
14
+ *
15
+ * ```js
16
+ * import sentio from '@sveltesentio/core/eslint';
17
+ * export default [
18
+ * {
19
+ * files: ['src/**\/*.ts'],
20
+ * plugins: { '@sveltesentio': sentio },
21
+ * rules: { '@sveltesentio/no-direct-time': 'error' },
22
+ * },
23
+ * ];
24
+ * ```
25
+ */
26
+
27
+ // Pull the concrete AST node shapes from ESLint's own `Rule.Node` union so we
28
+ // don't take a direct dependency on `@types/estree` (ESLint owns that peer).
29
+ type Node = Rule.Node;
30
+ type CallExpressionNode = Extract<Node, { type: 'CallExpression' }>;
31
+ type NewExpressionNode = Extract<Node, { type: 'NewExpression' }>;
32
+ type Callee = CallExpressionNode['callee'];
33
+
34
+ const CLOCK_HINT =
35
+ 'route time through the injected Clock — `useClock()`/`getClock()` ' +
36
+ '(or `testClock` in tests) from @sveltesentio/core — so it stays ' +
37
+ 'deterministic and testable';
38
+
39
+ /** Matches `<object>.<property>` where both are plain (non-computed) identifiers. */
40
+ function isMemberCall(
41
+ callee: Callee,
42
+ objectName: string,
43
+ propertyName: string,
44
+ ): boolean {
45
+ if (callee.type !== 'MemberExpression') return false;
46
+ if (callee.computed) return false;
47
+ if (callee.property.type !== 'Identifier') return false;
48
+ if (callee.property.name !== propertyName) return false;
49
+ return (
50
+ callee.object.type === 'Identifier' && callee.object.name === objectName
51
+ );
52
+ }
53
+
54
+ const noDirectTime: Rule.RuleModule = {
55
+ meta: {
56
+ type: 'problem',
57
+ docs: {
58
+ description:
59
+ 'disallow direct wall-clock / monotonic time reads; use the injected Clock',
60
+ recommended: true,
61
+ },
62
+ schema: [],
63
+ messages: {
64
+ dateNow: `Avoid \`Date.now()\` — ${CLOCK_HINT}.`,
65
+ newDate: `Avoid argument-less \`new Date()\` — ${CLOCK_HINT}.`,
66
+ performanceNow: `Avoid \`performance.now()\` — ${CLOCK_HINT}.`,
67
+ },
68
+ },
69
+
70
+ create(context: Rule.RuleContext): Rule.RuleListener {
71
+ return {
72
+ CallExpression(node: CallExpressionNode): void {
73
+ if (isMemberCall(node.callee, 'Date', 'now')) {
74
+ context.report({ node, messageId: 'dateNow' });
75
+ return;
76
+ }
77
+ if (isMemberCall(node.callee, 'performance', 'now')) {
78
+ context.report({ node, messageId: 'performanceNow' });
79
+ }
80
+ },
81
+
82
+ NewExpression(node: NewExpressionNode): void {
83
+ if (node.callee.type !== 'Identifier') return;
84
+ if (node.callee.name !== 'Date') return;
85
+ // `new Date(serverMs)` is an explicit, deterministic construction —
86
+ // only the zero-argument form reads ambient wall-clock time.
87
+ if (node.arguments.length === 0) {
88
+ context.report({ node, messageId: 'newDate' });
89
+ }
90
+ },
91
+ };
92
+ },
93
+ };
94
+
95
+ /** The flat-config plugin object (`plugins: { '@sveltesentio': sentioEslint }`). */
96
+ const sentioEslint = {
97
+ meta: { name: '@sveltesentio/core', version: '0.1.0' },
98
+ rules: {
99
+ 'no-direct-time': noDirectTime,
100
+ },
101
+ } satisfies {
102
+ meta: { name: string; version: string };
103
+ rules: Record<string, Rule.RuleModule>;
104
+ };
105
+
106
+ export { noDirectTime, sentioEslint };
107
+ export default sentioEslint;
package/src/index.ts CHANGED
@@ -39,5 +39,12 @@ export {
39
39
  strictCsp,
40
40
  } from './csp.js';
41
41
 
42
- export type { SentioPluginOptions } from './vite.js';
43
- export { sentioPlugin } from './vite.js';
42
+ export type {
43
+ BudgetViolation,
44
+ BundleBudget,
45
+ BundleLike,
46
+ SentioPluginOptions,
47
+ } from './vite.js';
48
+ export { checkBundleBudget, sentioPlugin } from './vite.js';
49
+
50
+ export { noDirectTime, sentioEslint } from './eslint.js';
package/src/vite.ts CHANGED
@@ -1,16 +1,89 @@
1
1
  import type { Plugin } from 'vite';
2
2
 
3
+ /** A budget map: output chunk file name → maximum allowed size in bytes. */
4
+ export type BundleBudget = Readonly<Record<string, number>>;
5
+
3
6
  export interface SentioPluginOptions {
4
7
  requiredEnv?: readonly string[];
5
8
  verbose?: boolean;
6
9
  virtualModule?: Readonly<Record<string, unknown>>;
10
+ /**
11
+ * Per-chunk size budgets (§2.9 perf budgets). Keys are output `fileName`s
12
+ * (exact match) — e.g. `{ 'entry.js': 150_000 }`. On `generateBundle`, any
13
+ * emitted chunk whose byte size exceeds its budget fails the build, unless
14
+ * {@link bundleBudgetWarnOnly} is set.
15
+ */
16
+ bundleBudget?: BundleBudget;
17
+ /** When true, over-budget chunks warn instead of failing the build. */
18
+ bundleBudgetWarnOnly?: boolean;
7
19
  }
8
20
 
9
21
  const VIRTUAL_ID = '$sentio';
10
22
  const RESOLVED_ID = '\0$sentio';
11
23
 
24
+ /** Minimal structural view of a Rollup/Rolldown output item we need to size. */
25
+ interface BundleEntry {
26
+ type: 'chunk' | 'asset';
27
+ code?: string;
28
+ source?: string | Uint8Array;
29
+ }
30
+
31
+ /** A bundle is a `fileName → entry` map, as passed to `generateBundle`. */
32
+ export type BundleLike = Readonly<Record<string, BundleEntry>>;
33
+
34
+ export interface BudgetViolation {
35
+ fileName: string;
36
+ size: number;
37
+ budget: number;
38
+ }
39
+
40
+ /** Byte size of a chunk's code or an asset's source. */
41
+ function entrySize(entry: BundleEntry): number {
42
+ if (entry.type === 'chunk') {
43
+ return entry.code === undefined ? 0 : Buffer.byteLength(entry.code, 'utf8');
44
+ }
45
+ const { source } = entry;
46
+ if (source === undefined) return 0;
47
+ if (typeof source === 'string') return Buffer.byteLength(source, 'utf8');
48
+ return source.byteLength;
49
+ }
50
+
51
+ /**
52
+ * Pure budget check: returns every output entry that exceeds its budget. An
53
+ * entry with no matching budget key is unconstrained. Exported for unit tests.
54
+ */
55
+ export function checkBundleBudget(
56
+ bundle: BundleLike,
57
+ budget: BundleBudget,
58
+ ): BudgetViolation[] {
59
+ const violations: BudgetViolation[] = [];
60
+ for (const [fileName, entry] of Object.entries(bundle)) {
61
+ const max = budget[fileName];
62
+ if (max === undefined) continue;
63
+ const size = entrySize(entry);
64
+ if (size > max) violations.push({ fileName, size, budget: max });
65
+ }
66
+ return violations;
67
+ }
68
+
69
+ function formatViolations(violations: readonly BudgetViolation[]): string {
70
+ const lines = violations.map(
71
+ (v) =>
72
+ ` - ${v.fileName}: ${v.size} B exceeds budget ${v.budget} B (+${
73
+ v.size - v.budget
74
+ } B)`,
75
+ );
76
+ return `[sentio] Bundle-size budget exceeded:\n${lines.join('\n')}`;
77
+ }
78
+
12
79
  export function sentioPlugin(options: SentioPluginOptions = {}): Plugin {
13
- const { requiredEnv = [], verbose = false, virtualModule = {} } = options;
80
+ const {
81
+ requiredEnv = [],
82
+ verbose = false,
83
+ virtualModule = {},
84
+ bundleBudget,
85
+ bundleBudgetWarnOnly = false,
86
+ } = options;
14
87
 
15
88
  return {
16
89
  name: 'vite-plugin-sentio',
@@ -52,5 +125,22 @@ export function sentioPlugin(options: SentioPluginOptions = {}): Plugin {
52
125
  );
53
126
  }
54
127
  },
128
+
129
+ generateBundle(_options, bundle) {
130
+ if (!bundleBudget || Object.keys(bundleBudget).length === 0) return;
131
+ const violations = checkBundleBudget(bundle, bundleBudget);
132
+ if (violations.length === 0) {
133
+ if (verbose) {
134
+ console.warn('[sentio] Bundle-size budget: all chunks within budget.');
135
+ }
136
+ return;
137
+ }
138
+ const message = formatViolations(violations);
139
+ if (bundleBudgetWarnOnly) {
140
+ console.warn(message);
141
+ return;
142
+ }
143
+ throw new Error(message);
144
+ },
55
145
  };
56
146
  }