@cooperco/nuxt-layer-base 1.0.5 → 1.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 CHANGED
@@ -2,81 +2,56 @@
2
2
 
3
3
  A foundational layer that provides essential configuration and tooling for Nuxt 4 projects.
4
4
 
5
- ## Features
5
+ Compatibility Date: 2025-07-15
6
6
 
7
- - TypeScript integration with strict mode enabled
8
- - ESLint configuration with stylistic rules for Vue templates
9
- - Test utilities integration
10
- - Internationalization (i18n) via `@nuxtjs/i18n`
11
- - Nuxt DevTools enabled
12
- - CI/CD workflows for code quality checks
7
+
13
8
 
14
- ## Development
9
+ ## About Nuxt Layers
15
10
 
16
- ```bash
17
- # Install dependencies
18
- npm install
19
-
20
- # Start development server
21
- npm run dev
22
-
23
- # Run ESLint
24
- npm run lint
25
-
26
- # Fix linting issues automatically
27
- npm run lint:fix
28
-
29
- # Run TypeScript type checking
30
- npm run typecheck
31
- ```
11
+ Nuxt Layers are a way to share configuration, modules, and app/server files across multiple Nuxt projects. An app “extends” a layer, and Nuxt will merge the layer’s modules, runtime configuration, and directory contents (like `app/`, `server/`, and `plugins/`) with the app’s own files. This lets teams centralize common tooling and conventions while each app remains free to add its own code.
32
12
 
33
- ## Configuration Details
13
+ Learn more in the official Nuxt Layers documentation: https://nuxt.com/docs/guide/going-further/layers
34
14
 
35
- ### Key Features
15
+ ## For app developers: Using this layer
36
16
 
37
- - **Compatibility Date:** 2025-07-15
38
- - **Strict TypeScript:** Enforces type safety throughout your projects
39
- - **ESLint Integration:** Pre-configured with stylistic rules for Vue templates
40
- - Custom ESLint rules for code style consistency
41
- - Vue template formatting rules
42
- - **Testing Utilities:** Built-in test utils for comprehensive testing
17
+ ### What this layer provides
43
18
 
44
- ### Configuration Details
19
+ - TypeScript with strict mode enabled
20
+ - ESLint via `@nuxt/eslint` with stylistic Vue template rules
21
+ - Internationalization (i18n) via `@nuxtjs/i18n`
22
+ - Nuxt DevTools enabled in development
23
+ - Optional error and event logging to Loggly (client and server), disabled by default
45
24
 
46
- The base layer includes:
25
+ ### Install in your app
47
26
 
48
- - Nuxt ESLint module (`@nuxt/eslint`)
49
- - Nuxt Test Utils module (`@nuxt/test-utils`)
50
- - Nuxt i18n module (`@nuxtjs/i18n`)
51
- - Nuxt DevTools enabled
52
- - TypeScript configuration with strict mode
53
- - Custom ESLint configuration with rules for:
54
- - JavaScript stylistic preferences
55
- - TypeScript unused variables handling
56
- - Vue template formatting
27
+ 1) Add the layer to your project as a dev dependency
57
28
 
58
- ## Internationalization (i18n)
29
+ ```bash
30
+ # npm
31
+ npm i -D @cooperco/nuxt-layer-base
59
32
 
60
- This layer integrates the Nuxt i18n module (`@nuxtjs/i18n`). You can override or extend its configuration in your application.
33
+ # pnpm
34
+ pnpm add -D @cooperco/nuxt-layer-base
61
35
 
62
- - Docs: https://i18n.nuxtjs.org
36
+ # yarn
37
+ yarn add -D @cooperco/nuxt-layer-base
38
+ ```
63
39
 
64
- Override example in a consuming app:
40
+ 2) Extend the layer in your app’s Nuxt config
65
41
 
66
42
  ```ts
67
43
  // nuxt.config.ts (in your app)
68
44
  export default defineNuxtConfig({
69
- extends: ['@cooperco/nuxt-layer-base'],
70
- i18n: {
71
- locales: [
72
- { code: 'en', language: 'en-US', name: 'English' },
73
- { code: 'fr', language: 'fr-FR', name: 'Français' }
74
- ],
75
- defaultLocale: 'en'
76
- }
45
+ extends: ['@cooperco/nuxt-layer-base']
77
46
  })
78
47
  ```
79
48
 
49
+ TypeScript strict mode and DevTools are active immediately. For ESLint, add a config in your app (see "ESLint in consumer apps").
50
+
51
+ ### Internationalization (i18n)
52
+
53
+ This layer integrates the Nuxt i18n module. Use it directly in your components; preferred usage is an SFC `<i18n>` block. You can also call `useI18n()` in scripts.
54
+
80
55
  Basic usage in a component:
81
56
 
82
57
  ```vue
@@ -86,202 +61,368 @@ const { t, locale } = useI18n()
86
61
 
87
62
  <template>
88
63
  <p>{{ t('hello') }}</p>
64
+ <small>Current locale: {{ locale }}</small>
65
+ <!-- Provide your own translation files in your app (e.g., locales/en.json) -->
89
66
  </template>
90
67
  ```
91
68
 
92
- Note: Provide your own translation files (for example, `locales/en.json`) in your application.
69
+ Preferred: single‑file component local messages using an <i18n> block
93
70
 
94
- ## Usage
71
+ ```vue
72
+ <script setup lang="ts">
73
+ const { t } = useI18n()
74
+ </script>
95
75
 
96
- To use this layer in your Nuxt project:
76
+ <template>
77
+ <h1>{{ t('welcome') }}</h1>
78
+ <p>{{ t('cta') }}</p>
79
+ </template>
97
80
 
98
- ```typescript
99
- // nuxt.config.ts
100
- export default defineNuxtConfig({
101
- extends: [
102
- '@cooperco/nuxt-layer-base'
103
- ]
104
- })
81
+ <i18n lang="json5">
82
+ {
83
+ en: {
84
+ welcome: 'Welcome',
85
+ cta: 'Click to continue'
86
+ },
87
+ fr: {
88
+ welcome: 'Bienvenue',
89
+ cta: 'Cliquez pour continuer'
90
+ }
91
+ }
92
+ </i18n>
105
93
  ```
106
94
 
107
- ## Nuxt 4 directory structure
108
- This layer follows Nuxt 4's app directory structure:
109
- - app/plugins/... for plugins (e.g., app/plugins/loggly.client.ts)
110
- - app/composables/... for composables (e.g., app/composables/useLoggly.ts)
111
-
112
- If you previously looked under plugins/ or composables/ at the layer root, note they have been relocated under app/ for Nuxt 4.
95
+ Both global files (e.g., `locales/en.json`) and per‑component `<i18n>` blocks are supported.
113
96
 
114
- ## Error logging (Loggly)
97
+ ### Logging (optional, Loggly‑backed)
115
98
 
116
- This base layer includes optional, secure error logging via Loggly using a server proxy. It is off by default and can be enabled per consuming app using environment variables.
99
+ When enabled via environment variables, the base layer will:
100
+ - Capture server-side errors (Nitro request lifecycle)
101
+ - Capture client-side errors (Nuxt app errors, Vue component errors, global `window` errors, unhandled rejections)
102
+ - Provide a `useLogger()` composable for custom logs (and a deprecated `useLoggly()` wrapper)
103
+ - Proxy all logs through the canonical route `/api/log`, where payloads are normalized and sensitive fields are scrubbed before forwarding to Loggly
117
104
 
118
- What you get when enabled:
119
- - Automatic server-side error capture (Nitro request errors)
120
- - Automatic client-side error capture (Nuxt/Vue/global browser errors)
121
- - Composable for custom logs from your features (useLoggly)
122
- - Centralized PII scrubbing on the server proxy before forwarding to Loggly
105
+ Enable in your app with environment variables:
123
106
 
124
- ### 1) Enable in your app (env vars)
125
- Set the following environment variables in your consuming app (do not expose the token on the client):
126
-
127
- - LOGGLY_ENABLED=true
128
- - LOGGLY_TOKEN=your-loggly-customer-token
129
- - LOGGLY_TAGS=nuxt,base,app-name,env (comma-separated; optional)
130
- - LOG_LEVEL=error (optional; error|warn|info|debug)
131
- - LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs (optional override)
107
+ - `LOGGLY_ENABLED=true`
108
+ - `LOGGLY_TOKEN=<your Loggly customer token>` (server-only)
109
+ - `LOGGLY_TAGS=app-name,env` (optional, comma-separated)
110
+ - `LOG_LEVEL=error` (optional; exposed to client for your own use)
111
+ - `LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs` (optional override, server-only)
132
112
 
133
113
  Notes
134
- - LOGGLY_TOKEN is server-only and never shipped to the client. The client sends logs to the server proxy at /api/loggly.
135
- - If LOGGLY_ENABLED is not true or the token is missing, logging is skipped.
136
-
137
- ### 2) Using the composable in your app
138
- The base layer exposes a composable you can call anywhere in your app.
114
+ - The token is never exposed to the client. Browsers only post to your app’s `/api/log` endpoint (with `/api/loggly` kept as a deprecated alias).
115
+ - If logging is disabled or a token is not provided, the logging code is effectively a no-op and the API returns `{ ok: false, skipped: true }`.
116
+ - `LOG_LEVEL` is exposed via runtime config but the base layer does not filter logs by level; you may use it in your own app logic.
117
+ - Back‑compat alias: `/api/loggly` remains available but is deprecated. Prefer `/api/log`.
118
+
119
+ Tags behavior
120
+ - Automatic, source‑specific tags are appended for captured errors:
121
+ - Nuxt app‑level errors: `client`, `nuxt`
122
+ - Vue component errors: `client`, `vue`
123
+ - Global window `error` events: `client`, `window`
124
+ - Unhandled rejections: `client`, `promise`
125
+ - Server‑side errors: `server`
126
+ - Your `LOGGLY_TAGS` (e.g., `app-name,prod`) and per‑call tags from `useLogger()` are merged with the automatic tags; duplicates are removed and the list is capped at 10 tags.
127
+
128
+ Composable usage:
139
129
 
140
- Example:
141
130
  ```ts
142
131
  // inside a component or any composable
143
- const log = useLoggly()
132
+ const log = useLogger()
144
133
  await log.error('Checkout failed', { orderId, step: 'place-order' }, ['checkout'])
134
+ await log.warn('Slow response', { endpoint: '/api/orders', ms: 850 })
145
135
  await log.info('User clicked CTA', { campaign: 'summer' }, ['marketing'])
136
+ await log.debug('State snapshot', { state })
146
137
  ```
147
138
 
148
- API:
149
- - error(message, meta?, tags?)
150
- - warn(message, meta?, tags?)
151
- - info(message, meta?, tags?)
152
- - debug(message, meta?, tags?)
153
-
154
- Tags are merged with your global LOGGLY_TAGS (duplicates removed, limited to 10).
155
-
156
- ### 3) Auto-captured errors
157
- When enabled, the base layer automatically forwards:
139
+ Automatically captured errors (when enabled):
158
140
  - Server: request-time exceptions via a Nitro plugin
159
- - Client: Nuxt app errors, Vue component errors, window error events, and unhandled promise rejections
160
-
161
- These are forwarded to /api/loggly, which safely shapes the payload before sending to Loggly.
141
+ - Client: Nuxt app errors, Vue component errors, `window` error events, unhandled promise rejections
162
142
 
163
- ### 4) Security and privacy
164
- - The server proxy masks common sensitive keys (password, token, authorization, etc.) in meta payloads.
165
- - Prefer adding any app-specific sensitive keys to your meta scrubbing in the proxy if needed.
166
- - Logging is fire-and-forget; failures to log do not block requests or UI.
143
+ Security and privacy:
144
+ - The server proxy masks common sensitive keys in `meta` (e.g., `password`, `token`, `authorization`, `ssn`, etc.)
145
+ - Logging is fire-and-forget; failures to log are swallowed to avoid breaking UX
167
146
 
168
- ### 5) Verifying locally
169
- 1) In your consuming app, set env vars (e.g. in .env.local):
147
+ Verify locally:
148
+ 1) Add env vars in your app (e.g., `.env.local`):
170
149
  ```
171
150
  LOGGLY_ENABLED=true
172
151
  LOGGLY_TOKEN=...your token...
173
- LOGGLY_TAGS=nuxt,base,local,app-name
174
- LOG_LEVEL=error
152
+ LOGGLY_TAGS=app-name,local
153
+ ```
154
+ 2) Start your app and trigger a test error.
155
+ 3) Check the network tab for `POST /api/log` and then confirm the entry in Loggly.
156
+
157
+ Troubleshooting:
158
+ - No logs? Ensure `LOGGLY_ENABLED=true` and `LOGGLY_TOKEN` is present in the server environment.
159
+ - Browser CORS or network errors? The browser never talks to Loggly directly; it only posts to `/api/log`.
160
+ - High volume? Consider adding sampling or batching at the proxy later if needed.
161
+
162
+ ### ESLint in consumer apps
163
+
164
+ This layer integrates ESLint via the `@nuxt/eslint` module. When you extend this layer, ESLint is available to your app; you only need to add a config file and scripts in your project.
165
+
166
+ 1) Create `eslint.config.mjs` in your app root (copy from this layer and adjust only if necessary)
167
+
168
+ ```js
169
+ // eslint.config.mjs (in your app)
170
+ // Nuxt generates a flat config at ./.nuxt/eslint.config.mjs
171
+ // We enable stylistic rules and add a few custom rules matching the base layer.
172
+ // @ts-check
173
+ import withNuxt from './.nuxt/eslint.config.mjs'
174
+
175
+ export default withNuxt({
176
+ rules: {
177
+ // Stylistic
178
+ '@stylistic/comma-dangle': ['error', 'never'],
179
+
180
+ // TypeScript
181
+ '@typescript-eslint/no-unused-vars': ['error', { caughtErrorsIgnorePattern: '^_' }],
182
+
183
+ // Vue template
184
+ 'vue/html-closing-bracket-newline': ['error', { multiline: 'never', selfClosingTag: { multiline: 'never' } }],
185
+ 'vue/html-closing-bracket-spacing': ['error', { selfClosingTag: 'never' }],
186
+ 'vue/html-indent': ['error', 2, { baseIndent: 0 }]
187
+ }
188
+ })
189
+ ```
190
+
191
+ 2) Add scripts and run ESLint
192
+
193
+ ```json
194
+ {
195
+ "scripts": {
196
+ "lint": "nuxt prepare && eslint .",
197
+ "lint:fix": "nuxt prepare && eslint . --fix"
198
+ }
199
+ }
200
+ ```
201
+
202
+ Notes
203
+ - The base layer enables stylistic mode via Nuxt (`eslint.config.stylistic: true`).
204
+ - Do not use Prettier alongside stylistic rules; remove Prettier configs/plugins from your app to avoid conflicts.
205
+
206
+ What this ESLint config enforces (in plain English)
207
+
208
+ - Base: It starts from Nuxt’s generated, project‑aware flat config (via `@nuxt/eslint`), then turns on stylistic mode for consistent formatting in JS/TS/Vue files.
209
+ - This layer adds a few focused rules to keep things consistent and practical:
210
+ - `@stylistic/comma-dangle: 'never'` — Disallow trailing commas in arrays/objects.
211
+ - Bad:
212
+ ```js
213
+ const user = {
214
+ id: 1,
215
+ name: 'Ada',
216
+ }
217
+ ```
218
+ - Good:
219
+ ```js
220
+ const user = {
221
+ id: 1,
222
+ name: 'Ada'
223
+ }
224
+ ```
225
+ - `@typescript-eslint/no-unused-vars` with `{ caughtErrorsIgnorePattern: '^_' }` — Flag unused variables, but allow a try/catch parameter that starts with `_` when you don’t use it.
226
+ - Good (explicitly ignored):
227
+ ```ts
228
+ try {
229
+ risky()
230
+ }
231
+ catch (_err) {
232
+ // intentionally ignored
233
+ }
234
+ ```
235
+ - `vue/html-closing-bracket-newline: { multiline: 'never', selfClosingTag: { multiline: 'never' } }` — Don’t put a newline before a closing bracket, even for multi‑line attribute lists.
236
+ - Bad:
237
+ ```vue
238
+ <MyComp
239
+ a="1"
240
+ b="2"
241
+ />
242
+ ```
243
+ - Good:
244
+ ```vue
245
+ <MyComp
246
+ a="1"
247
+ b="2" />
248
+ ```
249
+ - `vue/html-closing-bracket-spacing: { selfClosingTag: 'never' }` — No space before a self‑closing `/>`.
250
+ - Bad: `<MyComp />`
251
+ - Good: `<MyComp/>`
252
+ - `vue/html-indent: [2, { baseIndent: 0 }]` — Two‑space indentation in templates.
253
+ - Example:
254
+ ```vue
255
+ <template>
256
+ <div>
257
+ <span>Text</span>
258
+ </div>
259
+ </template>
260
+ ```
261
+
262
+ ### TypeScript in consumer apps
263
+
264
+ Strict mode is enabled by default. No configuration is required in your app.
265
+
266
+ Optional type checking script (install `vue-tsc` in your app):
267
+
268
+ ```bash
269
+ npm i -D vue-tsc
270
+ ```
271
+
272
+ ```json
273
+ {
274
+ "scripts": {
275
+ "typecheck": "nuxt prepare && vue-tsc -b --noEmit"
276
+ }
277
+ }
278
+ ```
279
+
280
+ ### GitHub Actions in your app (linting and type checks)
281
+
282
+ To run the same checks in your app’s GitHub repository:
283
+
284
+ 1) Copy workflow files into your app’s repo
285
+ - Create `.github/workflows/lint.yml` with a job that checks out your code, sets up Node, installs dependencies, and runs `npm run lint`.
286
+ - (Optional) Create `.github/workflows/typecheck.yml` to run `npm run typecheck` if you added the script.
287
+
288
+ Minimal examples you can adapt:
289
+
290
+ `.github/workflows/lint.yml`
291
+ ```yaml
292
+ name: Lint
293
+ on:
294
+ pull_request:
295
+ branches: ['main']
296
+ jobs:
297
+ lint:
298
+ runs-on: ubuntu-latest
299
+ steps:
300
+ - uses: actions/checkout@v4
301
+ - uses: actions/setup-node@v4
302
+ with:
303
+ node-version: 20
304
+ - run: npm ci
305
+ - run: npm run lint
306
+ ```
307
+
308
+ `.github/workflows/typecheck.yml`
309
+ ```yaml
310
+ name: Typecheck
311
+ on:
312
+ pull_request:
313
+ branches: ['main']
314
+ jobs:
315
+ typecheck:
316
+ runs-on: ubuntu-latest
317
+ steps:
318
+ - uses: actions/checkout@v4
319
+ - uses: actions/setup-node@v4
320
+ with:
321
+ node-version: 20
322
+ - run: npm ci
323
+ - run: npm run typecheck
175
324
  ```
176
- 2) Start your app and induce an error (e.g., throw new Error('Test') in onMounted of a test component).
177
- 3) Watch your network tab for POST /api/loggly (200 OK). Then check Loggly for the entry with your tags.
178
325
 
179
- ### 6) Troubleshooting
180
- - No logs? Ensure LOGGLY_ENABLED=true and LOGGLY_TOKEN is set in the server environment.
181
- - CORS or network errors from the browser? The client never talks to Loggly directly; it only posts to /api/loggly.
182
- - Too much volume? Consider adding sampling or batching later; the proxy makes this easy to change centrally.
326
+ 2) Configure GitHub as needed
327
+ - Enable Actions in your repository settings (if disabled).
328
+ - If your app installs private packages, set appropriate repository secrets (e.g., `NPM_TOKEN`, organization PATs) and reference them in the workflow. For public packages only, no extra secrets are required.
329
+
330
+ 3) Open a pull request to see the checks run.
331
+
332
+
183
333
 
334
+ ## For maintainers: Working on this layer
184
335
 
185
- ## Playground: Test Loggly in the base layer
336
+ ### Repository layout and Nuxt 4 directories
186
337
 
187
- A tiny playground app is included so you can run the base layer by itself and verify Loggly end‑to‑end before integrating into another app.
338
+ - Layer root: `layers/base`
339
+ - Nuxt 4 app directories are used inside the layer:
340
+ - `app/plugins/...` (e.g., `app/plugins/loggly.client.ts`)
341
+ - `app/composables/...` (e.g., `app/composables/useLogger.ts`; deprecated wrapper: `app/composables/useLoggly.ts`)
342
+
343
+ ### Development scripts
344
+
345
+ ```bash
346
+ # From layers/base
347
+ npm install
348
+ npm run dev # start a dev server for the layer (via Nuxt)
349
+ npm run lint # ESLint (with nuxt prepare)
350
+ npm run lint:fix # ESLint --fix
351
+ npm run typecheck # vue-tsc
352
+ ```
188
353
 
189
- Location: playground
354
+ ### Linting and TypeScript
190
355
 
191
- How it works:
192
- - The playground extends the base layer (extends: ['../layers/base']).
193
- - It includes a simple page to send logs via useLoggly and buttons to trigger client/server errors.
194
- - The server exposes GET /api/boom to intentionally throw and test server‑side logging.
356
+ - TypeScript strict mode is enforced via `nuxt.config.ts`
357
+ - ESLint is provided by `@nuxt/eslint` with stylistic rules enabled and custom rules in `eslint.config.mjs`
195
358
 
196
- Run it:
197
- 1) From the repo root:
198
- - cd playground && npm run dev
359
+ ### Logging implementation (overview)
199
360
 
200
- 2) Provide environment variables for Loggly in the playground (optional but required to actually send to Loggly):
201
- Copy playground/.env.example to playground/.env and fill in your values.
361
+ - Client plugin: `app/plugins/loggly.client.ts` hooks into Nuxt/Vue error streams and window events and posts to `/api/log`
362
+ - Composable: `app/composables/useLogger.ts` exposes `error|warn|info|debug` helpers posting to `/api/log` (deprecated wrapper `useLoggly.ts` delegates to `useLogger()`)
363
+ - Server plugin: `server/plugins/loggly.ts` hooks Nitro `error` events and posts to `/api/log`
364
+ - API endpoint (canonical): `server/api/log.post.ts` scrubs/normalizes payloads and forwards to Loggly using `LOGGLY_TOKEN`
365
+ - API endpoint (alias, deprecated): `server/api/loggly.post.ts` re-exports the canonical handler so `/api/loggly` continues to work
366
+ - Runtime config (see `nuxt.config.ts`):
367
+ - Server-only: `logglyToken`, `logglyEndpoint`
368
+ - Public: `logglyEnabled`, `logglyTags`, `logLevel`
369
+ - Supports both `LOGGLY_*` and `NUXT_PUBLIC_LOG_*` env vars where appropriate
202
370
 
203
- LOGGLY_ENABLED=true
204
- LOGGLY_TOKEN=your-loggly-customer-token
205
- LOGGLY_TAGS=nuxt,base,playground,local
206
- LOG_LEVEL=debug
207
- # Optional override
208
- # LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs
371
+ ### Environment files
209
372
 
210
- 3) Open http://localhost:3000
211
- - Click the Send error/warn/info/debug buttons to send custom logs.
212
- - Click Throw client error to exercise the client plugin.
213
- - Click Call /api/boom to exercise the server plugin.
373
+ - `layers/base/.env` is only for developing the base layer directly (not used by consuming apps)
374
+ - For the repo playground, use `playground/.env`
375
+ - For downstream apps, use `.env` or `.env.local` in the app’s root
214
376
 
215
- Notes:
216
- - If LOGGLY_ENABLED is not true or LOGGLY_TOKEN is missing, the server proxy will skip sending and respond with { ok: false, skipped: true }.
217
- - The token is server‑only; it is never exposed to the client. The browser only talks to /api/loggly on your dev server.
377
+ ### Playground
218
378
 
379
+ There is a simple playground app at `playground` that extends the base layer and exposes UI to trigger logging. This is useful for end‑to‑end verification during development.
219
380
 
220
- ---
381
+ Run from repo root:
382
+ ```bash
383
+ cd playground
384
+ npm run dev
385
+ ```
221
386
 
222
- ## Publishing to npm (tag-based)
387
+ ### Publishing to npm (tag-based)
223
388
 
224
- This layer is published using GitHub Actions when you push a tag that matches the base-vX.Y.Z pattern.
389
+ This package is published via GitHub Actions when you push a tag that matches `base-vX.Y.Z`.
225
390
 
226
391
  High-level flow:
227
- - Bump the version in layers/base/package.json (SemVer).
228
- - Commit and push your changes to main (or ensure the commit is on main).
229
- - Create and push a tag named base-vX.Y.Z (matching the package.json version).
230
- - The Publish Base Layer workflow installs deps, checks if that exact version already exists on npm, and if not, publishes privately with --access restricted.
392
+ - Bump the version in `layers/base/package.json` (SemVer)
393
+ - Commit and push to `main`
394
+ - Create and push a tag `base-vX.Y.Z` matching the version
395
+ - CI checks whether the version exists on npm and publishes if not
231
396
 
232
397
  Important notes:
233
- - Do NOT rely on npm version creating a tag for you (it will create vX.Y.Z). We use a custom tag prefix base-v.
234
- - First-time private publish is already configured via publishConfig.access: "restricted".
235
- - The workflow will skip if the version already exists (you'll see "Skipping; version already exists.").
236
-
237
- ### Step-by-step
238
-
239
- 1) Bump the version in layers/base/package.json
240
- - Option A (recommended): use npm version without creating a tag
241
- - Bash:
242
- ```bash
243
- cd layers/base
244
- npm version patch --no-git-tag-version # or minor | major
245
- ```
246
- - PowerShell:
247
- ```powershell
248
- Set-Location layers/base
249
- npm version patch --no-git-tag-version # or minor | major
250
- ```
251
- - Option B: manually edit the version field in layers/base/package.json (SemVer: MAJOR.MINOR.PATCH)
252
-
253
- 2) Commit and push the change (from repo root or layers/base)
398
+ - Do NOT rely on `npm version` to create the tag (it creates `vX.Y.Z`). Create `base-vX.Y.Z` yourself.
399
+ - `publishConfig.access` is set to `public`; publishes are public to npmjs.
400
+ - The workflow skips if the exact version already exists.
401
+
402
+ Step-by-step
403
+ 1) Bump version without creating a tag:
404
+ ```bash
405
+ cd layers/base
406
+ npm version patch --no-git-tag-version # or minor | major
407
+ ```
408
+ 2) Commit and push:
254
409
  ```bash
255
410
  git add layers/base/package.json
256
411
  git commit -m "chore(base): bump version"
257
412
  git push origin main
258
413
  ```
414
+ 3) Tag and push:
415
+ ```bash
416
+ cd layers/base
417
+ VERSION=$(node -p "require('./package.json').version")
418
+ cd ../..
419
+ git tag "base-v$VERSION"
420
+ git push origin "base-v$VERSION"
421
+ ```
422
+ 4) CI will publish
423
+ - Workflow: `.github/workflows/publish-base.yml`
424
+ - Auth: `NPM_TOKEN` GitHub secret
259
425
 
260
- 3) Create and push the tag using the new version
261
- - Get the new version value:
262
- - Bash:
263
- ```bash
264
- cd layers/base
265
- VERSION=$(node -p "require('./package.json').version")
266
- cd ../..
267
- git tag "base-v$VERSION"
268
- git push origin "base-v$VERSION"
269
- ```
270
- - PowerShell:
271
- ```powershell
272
- Set-Location layers/base
273
- $version = node -p "require('./package.json').version"
274
- Set-Location ../..
275
- git tag "base-v$version"
276
- git push origin "base-v$version"
277
- ```
278
-
279
- 4) GitHub Actions will publish
280
- - Workflow: .github/workflows/publish-base.yml
281
- - Auth: uses NPM_TOKEN (Classic Automation token) configured as a GitHub secret
282
- - Behavior: installs, checks npm view @cooperco/nuxt-layer-base@<version>, publishes if not found
283
-
284
- ### Troubleshooting
285
- - Version already exists: bump the version again (patch/minor/major) and push a new tag.
286
- - Auth errors (401/403): ensure NPM_TOKEN is set in repo secrets and org access is correct.
287
- - Registry mismatch: this repo’s .npmrc pins @cooperco to https://registry.npmjs.org/; keep that file intact.
426
+ Troubleshooting
427
+ - Version exists: bump again and retag
428
+ - 401/403: ensure `NPM_TOKEN` is configured and has access
@@ -0,0 +1,28 @@
1
+ export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
2
+
3
+ export const useLogger = () => {
4
+ const { public: pub } = useRuntimeConfig()
5
+ const enabled = !!pub?.logglyEnabled
6
+ const baseTags = (pub?.logglyTags || []) as string[]
7
+
8
+ const send = (
9
+ level: LogLevel,
10
+ message: string,
11
+ meta?: Record<string, unknown>,
12
+ tags: string[] = []
13
+ ) => {
14
+ if (!enabled) return
15
+ const mergedTags = [...new Set([...baseTags, ...tags])].slice(0, 10)
16
+ return $fetch('/api/log', {
17
+ method: 'POST',
18
+ body: { level, message, meta, tags: mergedTags }
19
+ }).catch(() => {})
20
+ }
21
+
22
+ return {
23
+ error: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('error', m, meta, tags),
24
+ warn: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('warn', m, meta, tags),
25
+ info: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('info', m, meta, tags),
26
+ debug: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('debug', m, meta, tags)
27
+ }
28
+ }
@@ -1,28 +1,6 @@
1
- export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
1
+ import { useLogger } from './useLogger'
2
2
 
3
- export const useLoggly = () => {
4
- const { public: pub } = useRuntimeConfig()
5
- const enabled = !!pub?.logglyEnabled
6
- const baseTags = (pub?.logglyTags || []) as string[]
7
-
8
- const send = (
9
- level: LogLevel,
10
- message: string,
11
- meta?: Record<string, unknown>,
12
- tags: string[] = []
13
- ) => {
14
- if (!enabled) return
15
- const mergedTags = [...new Set([...baseTags, ...tags])].slice(0, 10)
16
- return $fetch('/api/loggly', {
17
- method: 'POST',
18
- body: { level, message, meta, tags: mergedTags }
19
- }).catch(() => {})
20
- }
21
-
22
- return {
23
- error: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('error', m, meta, tags),
24
- warn: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('warn', m, meta, tags),
25
- info: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('info', m, meta, tags),
26
- debug: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('debug', m, meta, tags)
27
- }
28
- }
3
+ /**
4
+ * @deprecated Use `useLogger()` instead. This wrapper will be removed in a future major release.
5
+ */
6
+ export const useLoggly = () => useLogger()
@@ -2,12 +2,49 @@ export default defineNuxtPlugin((nuxtApp) => {
2
2
  const config = useRuntimeConfig()
3
3
  if (!config.public?.logglyEnabled) return
4
4
 
5
+ // Client-side de-duplication of errors that may surface through multiple channels
6
+ // (e.g., Vue errorHandler and window 'error'). We use a short-lived signature cache.
7
+ const recentSignatures = new Set<string>()
8
+ const remember = (sig: string) => {
9
+ recentSignatures.add(sig)
10
+ // Keep signatures briefly to collapse duplicate reports in the same tick/burst
11
+ setTimeout(() => {
12
+ recentSignatures.delete(sig)
13
+ }, 1000)
14
+ }
15
+
5
16
  const post = (payload: Record<string, unknown>) =>
6
- $fetch('/api/loggly', { method: 'POST', body: payload }).catch(() => { /* swallow */ })
17
+ $fetch('/api/log', { method: 'POST', body: payload }).catch(() => { /* swallow */ })
18
+
19
+ const hasMessage = (val: unknown): val is { message?: unknown } => {
20
+ return !!val && typeof val === 'object' && 'message' in (val as Record<string, unknown>)
21
+ }
22
+
23
+ const toSig = (err: unknown, fallbackMsg?: string): string | undefined => {
24
+ if (err instanceof Error) {
25
+ // name+message is sufficient across multiple client hooks; stacks can differ
26
+ return `${err.name}:${err.message}`
27
+ }
28
+ if (typeof err === 'string') return `str:${err}`
29
+ if (hasMessage(err)) {
30
+ return `obj:${String(err.message)}`
31
+ }
32
+ if (fallbackMsg) return `msg:${fallbackMsg}`
33
+ return undefined
34
+ }
35
+
36
+ const postOnce = (err: unknown, payload: Record<string, unknown>) => {
37
+ const sig = toSig(err, String(payload?.message ?? ''))
38
+ if (sig) {
39
+ if (recentSignatures.has(sig)) return
40
+ remember(sig)
41
+ }
42
+ return post(payload)
43
+ }
7
44
 
8
45
  // Nuxt app-level errors
9
46
  nuxtApp.hook('app:error', (err) => {
10
- post({
47
+ postOnce(err, {
11
48
  level: 'error',
12
49
  message: extractMessage(err) || 'App error',
13
50
  error: toErrorShape(err),
@@ -18,7 +55,7 @@ export default defineNuxtPlugin((nuxtApp) => {
18
55
  // Vue component-level errors
19
56
  const originalHandler = nuxtApp.vueApp.config.errorHandler
20
57
  nuxtApp.vueApp.config.errorHandler = (err, instance, info) => {
21
- post({
58
+ postOnce(err, {
22
59
  level: 'error',
23
60
  message: extractMessage(err) || 'Vue error',
24
61
  error: toErrorShape(err),
@@ -30,11 +67,11 @@ export default defineNuxtPlugin((nuxtApp) => {
30
67
 
31
68
  if (import.meta.client) {
32
69
  window.addEventListener('error', (event: ErrorEvent) => {
33
- post({ level: 'error', message: event.message, error: toErrorShape(event.error), tags: ['client', 'window'] })
70
+ postOnce(event.error, { level: 'error', message: event.message, error: toErrorShape(event.error), tags: ['client', 'window'] })
34
71
  })
35
72
  window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
36
73
  const reason = event.reason
37
- post({ level: 'error', message: extractMessage(reason) || 'Unhandled rejection', error: toErrorShape(reason), tags: ['client', 'promise'] })
74
+ postOnce(reason, { level: 'error', message: extractMessage(reason) || 'Unhandled rejection', error: toErrorShape(reason), tags: ['client', 'promise'] })
38
75
  })
39
76
  }
40
77
  })
package/eslint.config.mjs CHANGED
@@ -6,7 +6,12 @@ export default withNuxt({
6
6
  /* Stylistic */
7
7
  '@stylistic/comma-dangle': [
8
8
  'error',
9
- 'never'
9
+ 'only-multiline'
10
+ ],
11
+
12
+ '@stylistic/no-tabs': [
13
+ 'error',
14
+ { allowIndentationTabs: true }
10
15
  ],
11
16
 
12
17
  /* TS */
@@ -25,7 +30,7 @@ export default withNuxt({
25
30
  { selfClosingTag: 'never' }
26
31
  ],
27
32
  'vue/html-indent': [
28
- 'error', 2,
33
+ 'error', 'tab',
29
34
  { baseIndent: 0 }
30
35
  ]
31
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cooperco/nuxt-layer-base",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "scripts": {
@@ -22,13 +22,13 @@
22
22
  "author": "cooperco",
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
- "@nuxt/eslint": "^1.9.0",
26
- "@nuxtjs/i18n": "^10.1.0",
27
- "nuxt": "4.1.2"
25
+ "@nuxt/eslint": "^1.10.0",
26
+ "@nuxtjs/i18n": "^10.2.1",
27
+ "nuxt": "^4.2.1"
28
28
  },
29
29
  "devDependencies": {
30
- "@nuxt/test-utils": "^3.19.2",
31
- "eslint": "^9.37.0",
32
- "vue-tsc": "^3.1.1"
30
+ "@nuxt/test-utils": "^3.20.1",
31
+ "eslint": "^9.39.1",
32
+ "vue-tsc": "^3.1.4"
33
33
  }
34
34
  }
@@ -0,0 +1,58 @@
1
+ import { defineEventHandler, readBody } from 'h3'
2
+
3
+ type LogglyBody = {
4
+ level?: string
5
+ message?: string
6
+ error?: unknown
7
+ tags?: string[]
8
+ meta?: Record<string, unknown>
9
+ }
10
+
11
+ const sanitizeMeta = (input: unknown) => {
12
+ if (!input || typeof input !== 'object') return undefined
13
+ const blocked = ['password', 'token', 'authorization', 'auth', 'ssn', 'creditcard', 'credit_card']
14
+ const out: Record<string, unknown> = {}
15
+ for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
16
+ out[k] = blocked.includes(k.toLowerCase()) ? '[REDACTED]' : v
17
+ }
18
+ return out
19
+ }
20
+
21
+ const serializeError = (err: unknown) => {
22
+ if (!err || typeof err !== 'object') return undefined
23
+ const e = err as { name?: string, message?: unknown, stack?: unknown }
24
+ return {
25
+ name: e.name || 'Error',
26
+ message: String(e.message ?? 'Error'),
27
+ stack: typeof e.stack === 'string' ? e.stack.split('\n').slice(0, 20) : undefined
28
+ }
29
+ }
30
+
31
+ export default defineEventHandler(async (event) => {
32
+ const { logglyToken, logglyEndpoint, public: pub } = useRuntimeConfig()
33
+ if (!pub?.logglyEnabled || !logglyToken) {
34
+ return { ok: false, skipped: true }
35
+ }
36
+
37
+ const body = await readBody<LogglyBody>(event)
38
+
39
+ const scrubbed = {
40
+ level: body?.level ?? 'error',
41
+ message: body?.message?.toString().slice(0, 5000),
42
+ tags: [...new Set([...(pub.logglyTags || []), ...(body?.tags || [])])].slice(0, 10),
43
+ meta: sanitizeMeta(body?.meta),
44
+ error: body?.error ? serializeError(body.error) : undefined,
45
+ ts: new Date().toISOString()
46
+ }
47
+
48
+ const url = `${logglyEndpoint}/${encodeURIComponent(logglyToken)}/tag/${encodeURIComponent(scrubbed.tags.join(','))}`
49
+
50
+ try {
51
+ await $fetch(url, { method: 'POST', body: scrubbed })
52
+ return { ok: true }
53
+ }
54
+ catch {
55
+ // do not throw here to avoid cascading failures
56
+ return { ok: false }
57
+ }
58
+ })
@@ -1,58 +1 @@
1
- import { defineEventHandler, readBody } from 'h3'
2
-
3
- type LogglyBody = {
4
- level?: string
5
- message?: string
6
- error?: unknown
7
- tags?: string[]
8
- meta?: Record<string, unknown>
9
- }
10
-
11
- const sanitizeMeta = (input: unknown) => {
12
- if (!input || typeof input !== 'object') return undefined
13
- const blocked = ['password', 'token', 'authorization', 'auth', 'ssn', 'creditcard', 'credit_card']
14
- const out: Record<string, unknown> = {}
15
- for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
16
- out[k] = blocked.includes(k.toLowerCase()) ? '[REDACTED]' : v
17
- }
18
- return out
19
- }
20
-
21
- const serializeError = (err: unknown) => {
22
- if (!err || typeof err !== 'object') return undefined
23
- const e = err as { name?: string, message?: unknown, stack?: unknown }
24
- return {
25
- name: e.name || 'Error',
26
- message: String(e.message ?? 'Error'),
27
- stack: typeof e.stack === 'string' ? e.stack.split('\n').slice(0, 20) : undefined
28
- }
29
- }
30
-
31
- export default defineEventHandler(async (event) => {
32
- const { logglyToken, logglyEndpoint, public: pub } = useRuntimeConfig()
33
- if (!pub?.logglyEnabled || !logglyToken) {
34
- return { ok: false, skipped: true }
35
- }
36
-
37
- const body = await readBody<LogglyBody>(event)
38
-
39
- const scrubbed = {
40
- level: body?.level ?? 'error',
41
- message: body?.message?.toString().slice(0, 5000),
42
- tags: [...new Set([...(pub.logglyTags || []), ...(body?.tags || [])])].slice(0, 10),
43
- meta: sanitizeMeta(body?.meta),
44
- error: body?.error ? serializeError(body.error) : undefined,
45
- ts: new Date().toISOString()
46
- }
47
-
48
- const url = `${logglyEndpoint}/${encodeURIComponent(logglyToken)}/tag/${encodeURIComponent(scrubbed.tags.join(','))}`
49
-
50
- try {
51
- await $fetch(url, { method: 'POST', body: scrubbed })
52
- return { ok: true }
53
- }
54
- catch {
55
- // do not throw here to avoid cascading failures
56
- return { ok: false }
57
- }
58
- })
1
+ export { default } from './log.post'
@@ -3,13 +3,12 @@ type NitroErrorCtx = {
3
3
  path?: string
4
4
  method?: string
5
5
  node?: { res?: { statusCode?: number } }
6
+ context?: Record<string, unknown>
6
7
  }
7
8
  }
8
9
 
9
10
  const toErrorShape = (err: unknown) => {
10
- if (!err || typeof err !== 'object') {
11
- return undefined
12
- }
11
+ if (!err || typeof err !== 'object') return undefined
13
12
  const e = err as { name?: string, message?: unknown, stack?: unknown }
14
13
  return {
15
14
  name: e.name || 'Error',
@@ -22,13 +21,58 @@ export default defineNitroPlugin((nitroApp) => {
22
21
  const config = useRuntimeConfig()
23
22
  if (!config.public?.logglyEnabled || !config.logglyToken) return
24
23
 
24
+ // Minimal, process-local de-duplication to cover cases where Nitro emits
25
+ // the same error twice (e.g., once without a ctx.event and then again with it).
26
+ // We keep a tiny TTL so distinct errors still flow freely.
27
+ const recent = new Set<string>()
28
+ const remember = (sig: string) => {
29
+ recent.add(sig)
30
+ setTimeout(() => recent.delete(sig), 1000) // 1s window
31
+ }
32
+
33
+ const toSig = (error: unknown, ctx?: NitroErrorCtx) => {
34
+ let base: string | undefined
35
+ if (error && typeof error === 'object') {
36
+ const e = error as { name?: string, message?: unknown }
37
+ base = `${e.name || 'Error'}:${String(e.message ?? 'Error')}`
38
+ }
39
+ else if (typeof error === 'string') {
40
+ base = `str:${error}`
41
+ }
42
+ else {
43
+ base = 'unknown'
44
+ }
45
+ const m = ctx?.event?.method
46
+ const p = ctx?.event?.path
47
+ const s = ctx?.event?.node?.res?.statusCode
48
+ return [base, m, p, s].filter(v => v !== undefined && v !== '').join('|')
49
+ }
50
+
25
51
  nitroApp.hooks.hook('error', async (error: unknown, ctx?: NitroErrorCtx) => {
26
52
  try {
27
- await $fetch('/api/loggly', {
53
+ // Per-request guard: only log once per request lifecycle
54
+ const LOGGED_ONCE_KEY = '__loggerLoggedOnce' as const
55
+ type CtxStore = Record<string, unknown> & { [LOGGED_ONCE_KEY]?: boolean }
56
+ if (ctx?.event) {
57
+ const current = (ctx.event.context as CtxStore) || (ctx.event.context = {} as CtxStore)
58
+ if (current[LOGGED_ONCE_KEY]) return
59
+ current[LOGGED_ONCE_KEY] = true
60
+ }
61
+
62
+ // Process-local, short-lived dedup guard to catch duplicate emissions
63
+ const sig = toSig(error, ctx)
64
+ if (sig) {
65
+ if (recent.has(sig)) return
66
+ remember(sig)
67
+ }
68
+
69
+ await $fetch('/api/log', {
28
70
  method: 'POST',
29
71
  body: {
30
72
  level: 'error',
31
- message: (typeof error === 'object' && error && 'message' in error) ? String((error as { message?: unknown }).message) : 'Server error',
73
+ message: (typeof error === 'object' && error && 'message' in error)
74
+ ? String((error as { message?: unknown }).message)
75
+ : 'Server error',
32
76
  error: toErrorShape(error),
33
77
  meta: {
34
78
  url: ctx?.event?.path,