@zmdb/nuxt 1.0.0-beta.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.
@@ -0,0 +1,142 @@
1
+ import { createFetchTransport } from '@zmdb/client';
2
+ import type { ClientOptions } from '@zmdb/client';
3
+ import type { FetchLike } from '@zmdb/client/transport';
4
+ import { createZmdbVue } from '@zmdb/vue';
5
+ import type { QueryLoader, ZmdbVueBindings } from '@zmdb/vue';
6
+ import type { AsyncData, NuxtError, useAsyncData as useNuxtAsyncData } from 'nuxt/app';
7
+ import { computed, toValue } from 'vue';
8
+ import type { App, MaybeRefOrGetter } from 'vue';
9
+
10
+ export type NuxtGeneratedClientFactory<Client extends object> = (options: ClientOptions) => Client;
11
+
12
+ export interface ZmdbNuxtBindingOptions {
13
+ readonly useAsyncData: typeof useNuxtAsyncData;
14
+ readonly bindingName?: string;
15
+ }
16
+
17
+ export interface ZmdbNuxtAsyncData<Output> extends AsyncData<Output | undefined, NuxtError<unknown> | undefined> {}
18
+
19
+ export interface ZmdbNuxtBindings<Client extends object> extends ZmdbVueBindings<Client> {
20
+ useZmdbAsyncData<Input, Output>(
21
+ operationKey: string,
22
+ input: MaybeRefOrGetter<Input>,
23
+ load: QueryLoader<Client, Input, Output>,
24
+ ): ZmdbNuxtAsyncData<Output>;
25
+ }
26
+
27
+ export interface NuxtClientApplication {
28
+ readonly vueApp: Pick<App, 'use'>;
29
+ }
30
+
31
+ export interface ZmdbNuxtClientPluginOptions {
32
+ readonly baseUrl: string | URL;
33
+ readonly fetch?: FetchLike;
34
+ }
35
+
36
+ function encoded(value: unknown, seen: Set<object>): string {
37
+ if (value === null) return 'null';
38
+ if (typeof value === 'string' || typeof value === 'boolean') {
39
+ const result = JSON.stringify(value);
40
+ if (result === undefined) throw new Error('@zmdb/nuxt could not serialize a hydration input');
41
+ return result;
42
+ }
43
+ if (typeof value === 'number') {
44
+ if (!Number.isFinite(value)) throw new Error('@zmdb/nuxt hydration input numbers must be finite');
45
+ return Object.is(value, -0) ? '0' : String(value);
46
+ }
47
+ if (typeof value !== 'object') {
48
+ throw new Error(`@zmdb/nuxt hydration input contains non-serializable ${typeof value}`);
49
+ }
50
+ if (seen.has(value)) throw new Error('@zmdb/nuxt hydration input contains a cycle');
51
+
52
+ seen.add(value);
53
+ try {
54
+ if (Array.isArray(value)) {
55
+ const keys = Object.keys(value);
56
+ if (keys.length !== value.length || keys.some((key, index) => key !== String(index))) {
57
+ throw new Error('@zmdb/nuxt hydration input arrays must be dense and contain no named properties');
58
+ }
59
+ if (Object.getOwnPropertySymbols(value).length > 0) {
60
+ throw new Error('@zmdb/nuxt hydration input contains non-serializable symbol keys');
61
+ }
62
+ return `[${value.map(item => encoded(item, seen)).join(',')}]`;
63
+ }
64
+ const prototype = Object.getPrototypeOf(value);
65
+ if (prototype !== Object.prototype && prototype !== null) {
66
+ throw new Error('@zmdb/nuxt hydration input must contain only plain objects and arrays');
67
+ }
68
+ if (Object.getOwnPropertySymbols(value).length > 0) {
69
+ throw new Error('@zmdb/nuxt hydration input contains non-serializable symbol keys');
70
+ }
71
+ const entries = Object.entries(value).toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
72
+ return `{${entries
73
+ .map(([key, item]) => {
74
+ const encodedKey = JSON.stringify(key);
75
+ if (encodedKey === undefined) throw new Error('@zmdb/nuxt could not serialize a hydration key');
76
+ return `${encodedKey}:${encoded(item, seen)}`;
77
+ })
78
+ .join(',')}}`;
79
+ } finally {
80
+ seen.delete(value);
81
+ }
82
+ }
83
+
84
+ export function createNuxtDataKey(operationKey: string, input: unknown): string {
85
+ const selectedOperation = operationKey.trim();
86
+ if (selectedOperation.length === 0) {
87
+ throw new Error('@zmdb/nuxt operation key must be a non-empty string');
88
+ }
89
+ return `zmdb:${encoded([selectedOperation, input], new Set())}`;
90
+ }
91
+
92
+ function invoke<Client, Input, Output>(
93
+ load: QueryLoader<Client, Input, Output>,
94
+ client: Client,
95
+ input: Input,
96
+ signal: AbortSignal,
97
+ ): Promise<Output> {
98
+ try {
99
+ return Promise.resolve(load(client, input, signal));
100
+ } catch (error) {
101
+ return Promise.reject(error);
102
+ }
103
+ }
104
+
105
+ export function createZmdbNuxt<Client extends object>(options: ZmdbNuxtBindingOptions): ZmdbNuxtBindings<Client> {
106
+ const vue = createZmdbVue<Client>(options.bindingName ?? '@zmdb/nuxt');
107
+
108
+ return Object.freeze({
109
+ ...vue,
110
+ useZmdbAsyncData<Input, Output>(
111
+ operationKey: string,
112
+ input: MaybeRefOrGetter<Input>,
113
+ load: QueryLoader<Client, Input, Output>,
114
+ ): ZmdbNuxtAsyncData<Output> {
115
+ const client = vue.useZmdbClient();
116
+ const key = computed(() => createNuxtDataKey(operationKey, toValue(input)));
117
+ return options.useAsyncData<Output, unknown, Output, never[], undefined>(
118
+ key,
119
+ (_nuxtApp, context) => invoke(load, client, toValue(input), context.signal),
120
+ {
121
+ dedupe: 'cancel',
122
+ deep: false,
123
+ },
124
+ );
125
+ },
126
+ });
127
+ }
128
+
129
+ export function createZmdbNuxtClientPlugin<Client extends object>(
130
+ bindings: Pick<ZmdbNuxtBindings<Client>, 'createZmdbPlugin'>,
131
+ createClient: NuxtGeneratedClientFactory<Client>,
132
+ options: ZmdbNuxtClientPluginOptions,
133
+ ): (nuxtApp: NuxtClientApplication) => void {
134
+ return nuxtApp => {
135
+ const transport = createFetchTransport(options.fetch);
136
+ const client = createClient({
137
+ baseUrl: options.baseUrl,
138
+ transport,
139
+ });
140
+ nuxtApp.vueApp.use(bindings.createZmdbPlugin(client));
141
+ };
142
+ }
@@ -0,0 +1,34 @@
1
+ export type ZmdbNuxtForwardNameKind = 'header' | 'cookie';
2
+
3
+ const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
4
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
5
+ const RESERVED_FORWARD_HEADERS = new Set([
6
+ 'accept',
7
+ 'connection',
8
+ 'content-length',
9
+ 'content-type',
10
+ 'cookie',
11
+ 'host',
12
+ 'transfer-encoding',
13
+ ]);
14
+
15
+ export function normalizeForwardNames(
16
+ values: readonly string[] | undefined,
17
+ kind: ZmdbNuxtForwardNameKind,
18
+ ): readonly string[] {
19
+ const selected = values ?? [];
20
+ const normalized = selected.map(value => (kind === 'header' ? value.trim().toLowerCase() : value.trim()));
21
+ const pattern = kind === 'header' ? HEADER_NAME : COOKIE_NAME;
22
+ for (const value of normalized) {
23
+ if (!pattern.test(value)) {
24
+ throw new Error(`@zmdb/nuxt forward ${kind} name ${JSON.stringify(value)} is invalid`);
25
+ }
26
+ if (kind === 'header' && RESERVED_FORWARD_HEADERS.has(value)) {
27
+ throw new Error(`@zmdb/nuxt forward header ${value} is transport-owned; use forwardCookies for cookies`);
28
+ }
29
+ }
30
+ if (new Set(normalized).size !== normalized.length) {
31
+ throw new Error(`@zmdb/nuxt forward ${kind} names must not contain duplicates`);
32
+ }
33
+ return Object.freeze(normalized);
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { addPluginTemplate, defineNuxtModule, resolvePath } from 'nuxt/kit';
2
+
3
+ import { normalizeForwardNames } from './forwarding.js';
4
+
5
+ export interface ZmdbNuxtModuleOptions {
6
+ /**
7
+ * Application module exporting both the typed bindings and generated-client
8
+ * factory named below.
9
+ */
10
+ readonly integration: string;
11
+ readonly bindingExport?: string;
12
+ readonly clientFactoryExport?: string;
13
+ readonly baseUrl?: string;
14
+ readonly forwardHeaders?: readonly string[];
15
+ readonly forwardCookies?: readonly string[];
16
+ }
17
+
18
+ interface ResolvedModuleOptions {
19
+ readonly integration: string;
20
+ readonly bindingExport: string;
21
+ readonly clientFactoryExport: string;
22
+ readonly baseUrl: string;
23
+ readonly forwardHeaders: readonly string[];
24
+ readonly forwardCookies: readonly string[];
25
+ }
26
+
27
+ const IDENTIFIER = /^[$A-Z_a-z][$\w]*$/;
28
+
29
+ function requiredText(value: string | undefined, label: string): string {
30
+ const selected = value?.trim();
31
+ if (selected === undefined || selected.length === 0) {
32
+ throw new Error(`@zmdb/nuxt ${label} must be a non-empty string`);
33
+ }
34
+ return selected;
35
+ }
36
+
37
+ function exportName(value: string | undefined, fallback: string, label: string): string {
38
+ const selected = value ?? fallback;
39
+ if (!IDENTIFIER.test(selected)) {
40
+ throw new Error(`@zmdb/nuxt ${label} must be a JavaScript identifier`);
41
+ }
42
+ return selected;
43
+ }
44
+
45
+ async function resolvedOptions(options: ZmdbNuxtModuleOptions): Promise<ResolvedModuleOptions> {
46
+ const integration = await resolvePath(requiredText(options.integration, 'integration module'));
47
+ return Object.freeze({
48
+ integration,
49
+ bindingExport: exportName(options.bindingExport, 'zmdb', 'bindingExport'),
50
+ clientFactoryExport: exportName(options.clientFactoryExport, 'createApiClient', 'clientFactoryExport'),
51
+ baseUrl: requiredText(options.baseUrl ?? '/api', 'baseUrl'),
52
+ forwardHeaders: normalizeForwardNames(options.forwardHeaders, 'header'),
53
+ forwardCookies: normalizeForwardNames(options.forwardCookies, 'cookie'),
54
+ });
55
+ }
56
+
57
+ function integrationImport(options: ResolvedModuleOptions): string {
58
+ return `import { ${options.bindingExport} as zmdbBindings, ${options.clientFactoryExport} as createZmdbClient } from ${JSON.stringify(options.integration)};`;
59
+ }
60
+
61
+ function clientPlugin(options: ResolvedModuleOptions): string {
62
+ return [
63
+ "import { defineNuxtPlugin } from '#app';",
64
+ "import { createZmdbNuxtClientPlugin } from '@zmdb/nuxt/client';",
65
+ integrationImport(options),
66
+ '',
67
+ `export default defineNuxtPlugin(createZmdbNuxtClientPlugin(zmdbBindings, createZmdbClient, ${JSON.stringify({ baseUrl: options.baseUrl })}));`,
68
+ '',
69
+ ].join('\n');
70
+ }
71
+
72
+ function serverPlugin(options: ResolvedModuleOptions): string {
73
+ return [
74
+ "import { defineNuxtPlugin } from '#app';",
75
+ "import { useNitroApp } from 'nitropack/runtime';",
76
+ "import { createZmdbNuxtServerPlugin } from '@zmdb/nuxt/server';",
77
+ integrationImport(options),
78
+ '',
79
+ 'export default defineNuxtPlugin(',
80
+ ' createZmdbNuxtServerPlugin(zmdbBindings, createZmdbClient, {',
81
+ ` baseUrl: ${JSON.stringify(options.baseUrl)},`,
82
+ ` forwardHeaders: ${JSON.stringify(options.forwardHeaders)},`,
83
+ ` forwardCookies: ${JSON.stringify(options.forwardCookies)},`,
84
+ ' fetch: useNitroApp().localFetch,',
85
+ ' }),',
86
+ ');',
87
+ '',
88
+ ].join('\n');
89
+ }
90
+
91
+ export const zmdbNuxtModule = defineNuxtModule<ZmdbNuxtModuleOptions>({
92
+ meta: {
93
+ name: '@zmdb/nuxt',
94
+ configKey: 'zmdb',
95
+ compatibility: {
96
+ nuxt: '>=4.5.2 <5.0.0',
97
+ },
98
+ },
99
+ async setup(options) {
100
+ const resolved = await resolvedOptions(options);
101
+ addPluginTemplate({
102
+ filename: 'zmdb.client.mjs',
103
+ getContents: () => clientPlugin(resolved),
104
+ });
105
+ addPluginTemplate({
106
+ filename: 'zmdb.server.mjs',
107
+ getContents: () => serverPlugin(resolved),
108
+ });
109
+ },
110
+ });
111
+
112
+ export default zmdbNuxtModule;
@@ -0,0 +1,128 @@
1
+ import { createFetchTransport } from '@zmdb/client';
2
+ import type { ClientRequest, ClientTransport } from '@zmdb/client';
3
+ import type { FetchLike } from '@zmdb/client/transport';
4
+ import type { App } from 'vue';
5
+
6
+ import type { NuxtGeneratedClientFactory, ZmdbNuxtBindings } from '../client/index.js';
7
+ import { normalizeForwardNames } from '../forwarding.js';
8
+
9
+ export interface ZmdbNuxtForwardingOptions {
10
+ readonly forwardHeaders?: readonly string[];
11
+ readonly forwardCookies?: readonly string[];
12
+ }
13
+
14
+ export interface ZmdbNuxtServerPluginOptions extends ZmdbNuxtForwardingOptions {
15
+ readonly baseUrl: string | URL;
16
+ readonly fetch: FetchLike;
17
+ }
18
+
19
+ export interface NuxtServerApplication {
20
+ readonly vueApp: Pick<App, 'use'>;
21
+ readonly ssrContext?: {
22
+ readonly event?: unknown;
23
+ };
24
+ }
25
+
26
+ interface NuxtRequestEvent {
27
+ readonly headers: Headers;
28
+ }
29
+
30
+ function cookiePairs(value: string | null | undefined): ReadonlyMap<string, string> {
31
+ const pairs = new Map<string, string>();
32
+ for (const segment of value?.split(';') ?? []) {
33
+ const separator = segment.indexOf('=');
34
+ if (separator <= 0) continue;
35
+ const name = segment.slice(0, separator).trim();
36
+ if (name.length === 0 || pairs.has(name)) continue;
37
+ pairs.set(name, segment.slice(separator + 1).trim());
38
+ }
39
+ return pairs;
40
+ }
41
+
42
+ function selectedCookieHeader(
43
+ incoming: ReadonlyMap<string, string>,
44
+ names: readonly string[],
45
+ operationCookie: string | undefined,
46
+ ): string | undefined {
47
+ const selected = new Map<string, string>();
48
+ for (const name of names) {
49
+ const value = incoming.get(name);
50
+ if (value !== undefined) selected.set(name, value);
51
+ }
52
+ for (const [name, value] of cookiePairs(operationCookie)) selected.set(name, value);
53
+ if (selected.size === 0) return undefined;
54
+ return [...selected].map(([name, value]) => `${name}=${value}`).join('; ');
55
+ }
56
+
57
+ function forwardedHeaders(incoming: Headers, names: readonly string[]): Headers {
58
+ const selected = new Headers();
59
+ for (const name of names) {
60
+ const value = incoming.get(name);
61
+ if (value !== null) selected.set(name, value);
62
+ }
63
+ return selected;
64
+ }
65
+
66
+ function requestInit(init: RequestInit | undefined, forwarded: Headers, cookie: string | undefined): RequestInit {
67
+ const headers = new Headers(init?.headers);
68
+ forwarded.forEach((value, name) => {
69
+ if (!headers.has(name)) headers.set(name, value);
70
+ });
71
+ if (cookie !== undefined && !headers.has('cookie')) headers.set('cookie', cookie);
72
+ return init === undefined ? { headers } : { ...init, headers };
73
+ }
74
+
75
+ function withoutCookie(request: ClientRequest): ClientRequest {
76
+ if (request.headers.cookie === undefined) return request;
77
+ const headers = Object.freeze(
78
+ Object.fromEntries(Object.entries(request.headers).filter(([name]) => name !== 'cookie')),
79
+ );
80
+ return Object.freeze({
81
+ ...request,
82
+ headers,
83
+ });
84
+ }
85
+
86
+ function requestEvent(value: unknown): NuxtRequestEvent {
87
+ if (typeof value !== 'object' || value === null || !('headers' in value)) {
88
+ throw new Error('@zmdb/nuxt server plugin requires the current Nitro request event');
89
+ }
90
+ const headers = value.headers;
91
+ if (!(headers instanceof Headers)) {
92
+ throw new Error('@zmdb/nuxt server plugin requires the current Nitro request event');
93
+ }
94
+ return Object.freeze({ headers });
95
+ }
96
+
97
+ export function createNuxtServerTransport(
98
+ fetch: FetchLike,
99
+ incomingHeaders: Headers,
100
+ options: ZmdbNuxtForwardingOptions = {},
101
+ ): ClientTransport {
102
+ const headerNames = normalizeForwardNames(options.forwardHeaders, 'header');
103
+ const cookieNames = normalizeForwardNames(options.forwardCookies, 'cookie');
104
+ const forwarded = forwardedHeaders(incomingHeaders, headerNames);
105
+ const incomingCookies = cookiePairs(incomingHeaders.get('cookie'));
106
+
107
+ return request => {
108
+ const cookie = selectedCookieHeader(incomingCookies, cookieNames, request.headers.cookie);
109
+ const requestFetch: FetchLike = (input, init) => fetch(input, requestInit(init, forwarded, cookie));
110
+ return createFetchTransport(requestFetch)(withoutCookie(request));
111
+ };
112
+ }
113
+
114
+ export function createZmdbNuxtServerPlugin<Client extends object>(
115
+ bindings: Pick<ZmdbNuxtBindings<Client>, 'createZmdbPlugin'>,
116
+ createClient: NuxtGeneratedClientFactory<Client>,
117
+ options: ZmdbNuxtServerPluginOptions,
118
+ ): (nuxtApp: NuxtServerApplication) => void {
119
+ return nuxtApp => {
120
+ const event = requestEvent(nuxtApp.ssrContext?.event);
121
+ const transport = createNuxtServerTransport(options.fetch, event.headers, options);
122
+ const client = createClient({
123
+ baseUrl: options.baseUrl,
124
+ transport,
125
+ });
126
+ nuxtApp.vueApp.use(bindings.createZmdbPlugin(client));
127
+ };
128
+ }