@cooperco/nuxt-layer-base 1.0.5 → 1.1.1

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
13
-
14
- ## Development
15
-
16
- ```bash
17
- # Install dependencies
18
- npm install
7
+
19
8
 
20
- # Start development server
21
- npm run dev
22
-
23
- # Run ESLint
24
- npm run lint
9
+ ## About Nuxt Layers
25
10
 
26
- # Fix linting issues automatically
27
- npm run lint:fix
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.
28
12
 
29
- # Run TypeScript type checking
30
- npm run typecheck
31
- ```
13
+ Learn more in the official Nuxt Layers documentation: https://nuxt.com/docs/guide/going-further/layers
32
14
 
33
- ## Configuration Details
15
+ ## For app developers: Using this layer
34
16
 
35
- ### Key Features
17
+ ### What this layer provides
36
18
 
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
43
-
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,397 @@ 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)
95
+ Both global files (e.g., `locales/en.json`) and per‑component `<i18n>` blocks are supported.
111
96
 
112
- If you previously looked under plugins/ or composables/ at the layer root, note they have been relocated under app/ for Nuxt 4.
97
+ ### Logging (optional, Loggly‑backed)
113
98
 
114
- ## Error logging (Loggly)
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
115
104
 
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.
105
+ Enable in your app with environment variables:
117
106
 
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
123
-
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
141
+ - Client: Nuxt app errors, Vue component errors, `window` error events, unhandled promise rejections
160
142
 
161
- These are forwarded to /api/loggly, which safely shapes the payload before sending to Loggly.
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
162
146
 
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.
167
-
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': [
179
+ 'error',
180
+ 'only-multiline'
181
+ ],
182
+ '@stylistic/no-tabs': [
183
+ 'error',
184
+ { allowIndentationTabs: true }
185
+ ],
186
+
187
+ /* TS */
188
+ '@typescript-eslint/no-unused-vars': [
189
+ 'error',
190
+ { caughtErrorsIgnorePattern: '^_' }
191
+ ],
192
+
193
+ /* Vue */
194
+ 'vue/html-closing-bracket-newline': [
195
+ 'error',
196
+ { multiline: 'never', selfClosingTag: { multiline: 'never' } }
197
+ ],
198
+ 'vue/html-closing-bracket-spacing': [
199
+ 'error',
200
+ { selfClosingTag: 'never' }
201
+ ],
202
+ 'vue/html-indent': [
203
+ 'error', 'tab',
204
+ { baseIndent: 0 }
205
+ ],
206
+ 'vue/multi-word-component-names': ['error', {
207
+ ignores: []
208
+ }],
209
+ 'vue/component-name-in-template-casing': [
210
+ 'error',
211
+ 'kebab-case',
212
+ { registeredComponentsOnly: false, ignores: [] }
213
+ ],
214
+ 'vue/component-options-name-casing': ['error', 'kebab-case'],
215
+ 'vue/component-definition-name-casing': ['error', 'kebab-case']
216
+ }
217
+ })
218
+ ```
219
+
220
+ 2) Add scripts and run ESLint
221
+
222
+ ```json
223
+ {
224
+ "scripts": {
225
+ "lint": "nuxt prepare && eslint .",
226
+ "lint:fix": "nuxt prepare && eslint . --fix"
227
+ }
228
+ }
229
+ ```
230
+
231
+ Notes
232
+ - The base layer enables stylistic mode via Nuxt (`eslint.config.stylistic: true`).
233
+ - Do not use Prettier alongside stylistic rules; remove Prettier configs/plugins from your app to avoid conflicts.
234
+ - Tabs are allowed for indentation (including in Vue templates). The `no-tabs` rule is enabled but permits indentation tabs.
235
+
236
+ What this ESLint config enforces (in plain English)
237
+
238
+ - 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.
239
+ - This layer adds a few focused rules to keep things consistent and practical:
240
+ - `@stylistic/comma-dangle: 'only-multiline'` — Allow trailing commas only when the list spans multiple lines; disallow them on single‑line lists.
241
+ - Good (multiline, trailing comma ok):
242
+ ```js
243
+ const user = {
244
+ id: 1,
245
+ name: 'Ada',
246
+ }
247
+ ```
248
+ - Good (single‑line, no trailing comma): `const arr = [1, 2, 3]`
249
+ - `@stylistic/no-tabs` with `{ allowIndentationTabs: true }` — Tabs are allowed for indentation but tabs elsewhere are flagged.
250
+ - `@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.
251
+ - Good (explicitly ignored):
252
+ ```ts
253
+ try {
254
+ risky()
255
+ }
256
+ catch (_err) {
257
+ // intentionally ignored
258
+ }
259
+ ```
260
+ - `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.
261
+ - Bad:
262
+ ```vue
263
+ <my-comp
264
+ a="1"
265
+ b="2"
266
+ />
267
+ ```
268
+ - Good:
269
+ ```vue
270
+ <my-comp
271
+ a="1"
272
+ b="2" />
273
+ ```
274
+ - `vue/html-closing-bracket-spacing: { selfClosingTag: 'never' }` — No space before a self‑closing `/>`.
275
+ - Bad: `<my-comp />`
276
+ - Good: `<my-comp/>`
277
+ - `vue/html-indent: ['tab', { baseIndent: 0 }]` — Use tabs for indentation in Vue templates.
278
+ - Example:
279
+ ```vue
280
+ <template>
281
+ <div>
282
+ <span>Text</span>
283
+ </div>
284
+ </template>
285
+ ```
286
+ - `vue/multi-word-component-names` — Enforce multi‑word component names (no single generic names like `Header` conflicts). Configure ignores if needed.
287
+ - `vue/component-name-in-template-casing: 'kebab-case'` — In templates, component tags must be kebab‑case (e.g., `<my-widget/>`, not `<MyWidget/>`).
288
+ - `vue/component-options-name-casing: 'kebab-case'` — In SFC component options, the `name` should be kebab‑case.
289
+ - `vue/component-definition-name-casing: 'kebab-case'` — For component definitions in script, enforce kebab‑case names.
290
+
291
+ ### TypeScript in consumer apps
292
+
293
+ Strict mode is enabled by default. No configuration is required in your app.
294
+
295
+ Optional type checking script (install `vue-tsc` in your app):
296
+
297
+ ```bash
298
+ npm i -D vue-tsc
299
+ ```
300
+
301
+ ```json
302
+ {
303
+ "scripts": {
304
+ "typecheck": "nuxt prepare && vue-tsc -b --noEmit"
305
+ }
306
+ }
307
+ ```
308
+
309
+ ### GitHub Actions in your app (linting and type checks)
310
+
311
+ To run the same checks in your app’s GitHub repository:
312
+
313
+ 1) Copy workflow files into your app’s repo
314
+ - Create `.github/workflows/lint.yml` with a job that checks out your code, sets up Node, installs dependencies, and runs `npm run lint`.
315
+ - (Optional) Create `.github/workflows/typecheck.yml` to run `npm run typecheck` if you added the script.
316
+
317
+ Minimal examples you can adapt:
318
+
319
+ `.github/workflows/lint.yml`
320
+ ```yaml
321
+ name: Lint
322
+ on:
323
+ pull_request:
324
+ branches: ['main']
325
+ jobs:
326
+ lint:
327
+ runs-on: ubuntu-latest
328
+ steps:
329
+ - uses: actions/checkout@v4
330
+ - uses: actions/setup-node@v4
331
+ with:
332
+ node-version: 20
333
+ - run: npm ci
334
+ - run: npm run lint
335
+ ```
336
+
337
+ `.github/workflows/typecheck.yml`
338
+ ```yaml
339
+ name: Typecheck
340
+ on:
341
+ pull_request:
342
+ branches: ['main']
343
+ jobs:
344
+ typecheck:
345
+ runs-on: ubuntu-latest
346
+ steps:
347
+ - uses: actions/checkout@v4
348
+ - uses: actions/setup-node@v4
349
+ with:
350
+ node-version: 20
351
+ - run: npm ci
352
+ - run: npm run typecheck
175
353
  ```
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
354
 
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.
355
+ 2) Configure GitHub as needed
356
+ - Enable Actions in your repository settings (if disabled).
357
+ - 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.
183
358
 
359
+ 3) Open a pull request to see the checks run.
184
360
 
185
- ## Playground: Test Loggly in the base layer
361
+
186
362
 
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.
363
+ ## For maintainers: Working on this layer
364
+
365
+ ### Repository layout and Nuxt 4 directories
366
+
367
+ - Layer root: `layers/base`
368
+ - Nuxt 4 app directories are used inside the layer:
369
+ - `app/plugins/...` (e.g., `app/plugins/loggly.client.ts`)
370
+ - `app/composables/...` (e.g., `app/composables/useLogger.ts`; deprecated wrapper: `app/composables/useLoggly.ts`)
371
+
372
+ ### Development scripts
373
+
374
+ ```bash
375
+ # From layers/base
376
+ npm install
377
+ npm run dev # start a dev server for the layer (via Nuxt)
378
+ npm run lint # ESLint (with nuxt prepare)
379
+ npm run lint:fix # ESLint --fix
380
+ npm run typecheck # vue-tsc
381
+ ```
188
382
 
189
- Location: playground
383
+ ### Linting and TypeScript
190
384
 
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.
385
+ - TypeScript strict mode is enforced via `nuxt.config.ts`
386
+ - ESLint is provided by `@nuxt/eslint` with stylistic rules enabled and custom rules in `eslint.config.mjs`
195
387
 
196
- Run it:
197
- 1) From the repo root:
198
- - cd playground && npm run dev
388
+ ### Logging implementation (overview)
199
389
 
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.
390
+ - Client plugin: `app/plugins/loggly.client.ts` hooks into Nuxt/Vue error streams and window events and posts to `/api/log`
391
+ - Composable: `app/composables/useLogger.ts` exposes `error|warn|info|debug` helpers posting to `/api/log` (deprecated wrapper `useLoggly.ts` delegates to `useLogger()`)
392
+ - Server plugin: `server/plugins/loggly.ts` hooks Nitro `error` events and posts to `/api/log`
393
+ - API endpoint (canonical): `server/api/log.post.ts` scrubs/normalizes payloads and forwards to Loggly using `LOGGLY_TOKEN`
394
+ - API endpoint (alias, deprecated): `server/api/loggly.post.ts` re-exports the canonical handler so `/api/loggly` continues to work
395
+ - Runtime config (see `nuxt.config.ts`):
396
+ - Server-only: `logglyToken`, `logglyEndpoint`
397
+ - Public: `logglyEnabled`, `logglyTags`, `logLevel`
398
+ - Supports both `LOGGLY_*` and `NUXT_PUBLIC_LOG_*` env vars where appropriate
202
399
 
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
400
+ ### Environment files
209
401
 
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.
402
+ - `layers/base/.env` is only for developing the base layer directly (not used by consuming apps)
403
+ - For the repo playground, use `playground/.env`
404
+ - For downstream apps, use `.env` or `.env.local` in the app’s root
214
405
 
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.
406
+ ### Playground
218
407
 
408
+ 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
409
 
220
- ---
410
+ Run from repo root:
411
+ ```bash
412
+ cd playground
413
+ npm run dev
414
+ ```
221
415
 
222
- ## Publishing to npm (tag-based)
416
+ ### Publishing to npm (tag-based)
223
417
 
224
- This layer is published using GitHub Actions when you push a tag that matches the base-vX.Y.Z pattern.
418
+ This package is published via GitHub Actions when you push a tag that matches `base-vX.Y.Z`.
225
419
 
226
420
  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.
421
+ - Bump the version in `layers/base/package.json` (SemVer)
422
+ - Commit and push to `main`
423
+ - Create and push a tag `base-vX.Y.Z` matching the version
424
+ - CI checks whether the version exists on npm and publishes if not
231
425
 
232
426
  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)
427
+ - Do NOT rely on `npm version` to create the tag (it creates `vX.Y.Z`). Create `base-vX.Y.Z` yourself.
428
+ - `publishConfig.access` is set to `public`; publishes are public to npmjs.
429
+ - The workflow skips if the exact version already exists.
430
+
431
+ Step-by-step
432
+ 1) Bump version without creating a tag:
433
+ ```bash
434
+ cd layers/base
435
+ npm version patch --no-git-tag-version # or minor | major
436
+ ```
437
+ 2) Commit and push:
254
438
  ```bash
255
439
  git add layers/base/package.json
256
440
  git commit -m "chore(base): bump version"
257
441
  git push origin main
258
442
  ```
443
+ 3) Tag and push:
444
+ ```bash
445
+ cd layers/base
446
+ VERSION=$(node -p "require('./package.json').version")
447
+ cd ../..
448
+ git tag "base-v$VERSION"
449
+ git push origin "base-v$VERSION"
450
+ ```
451
+ 4) CI will publish
452
+ - Workflow: `.github/workflows/publish-base.yml`
453
+ - Auth: `NPM_TOKEN` GitHub secret
259
454
 
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.
455
+ Troubleshooting
456
+ - Version exists: bump again and retag
457
+ - 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,9 +30,19 @@ 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
+ ],
36
+ 'vue/multi-word-component-names': ['error', {
37
+ ignores: []
38
+ }],
39
+ 'vue/component-name-in-template-casing': [
40
+ 'error',
41
+ 'kebab-case',
42
+ { registeredComponentsOnly: false, ignores: [] }
43
+ ],
44
+ 'vue/component-options-name-casing': ['error', 'kebab-case'],
45
+ 'vue/component-definition-name-casing': ['error', 'kebab-case']
31
46
  }
32
47
  })
33
48
 
package/nuxt.config.ts CHANGED
@@ -30,6 +30,10 @@ export default defineNuxtConfig({
30
30
  config: {
31
31
  stylistic: true
32
32
  }
33
+ },
34
+
35
+ i18n: {
36
+ defaultLocale: 'en'
33
37
  }
34
38
 
35
39
  })
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.1",
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
+ "@nuxtjs/i18n": "^10.2.1",
26
+ "nuxt": "^4.2.2"
28
27
  },
29
28
  "devDependencies": {
30
- "@nuxt/test-utils": "^3.19.2",
31
- "eslint": "^9.37.0",
32
- "vue-tsc": "^3.1.1"
29
+ "@nuxt/eslint": "^1.10.0",
30
+ "@nuxt/test-utils": "^3.20.1",
31
+ "eslint": "^9.39.2",
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,