javascript-solid-server 0.0.2

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,162 @@
1
+ import * as storage from '../storage/filesystem.js';
2
+ import { getAllHeaders } from '../ldp/headers.js';
3
+ import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
4
+ import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
5
+
6
+ /**
7
+ * Handle GET request
8
+ */
9
+ export async function handleGet(request, reply) {
10
+ const urlPath = request.url.split('?')[0]; // Remove query string
11
+ const stats = await storage.stat(urlPath);
12
+
13
+ if (!stats) {
14
+ return reply.code(404).send({ error: 'Not Found' });
15
+ }
16
+
17
+ const origin = request.headers.origin;
18
+
19
+ // Handle container
20
+ if (stats.isDirectory) {
21
+ const entries = await storage.listContainer(urlPath);
22
+ const baseUrl = `${request.protocol}://${request.hostname}${urlPath}`;
23
+ const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
24
+
25
+ const headers = getAllHeaders({
26
+ isContainer: true,
27
+ etag: stats.etag,
28
+ contentType: 'application/ld+json',
29
+ origin
30
+ });
31
+
32
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
33
+ return reply.send(serializeJsonLd(jsonLd));
34
+ }
35
+
36
+ // Handle resource
37
+ const content = await storage.read(urlPath);
38
+ if (content === null) {
39
+ return reply.code(500).send({ error: 'Read error' });
40
+ }
41
+
42
+ const contentType = getContentType(urlPath);
43
+ const headers = getAllHeaders({
44
+ isContainer: false,
45
+ etag: stats.etag,
46
+ contentType,
47
+ origin
48
+ });
49
+
50
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
51
+ return reply.send(content);
52
+ }
53
+
54
+ /**
55
+ * Handle HEAD request
56
+ */
57
+ export async function handleHead(request, reply) {
58
+ const urlPath = request.url.split('?')[0];
59
+ const stats = await storage.stat(urlPath);
60
+
61
+ if (!stats) {
62
+ return reply.code(404).send();
63
+ }
64
+
65
+ const origin = request.headers.origin;
66
+ const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
67
+
68
+ const headers = getAllHeaders({
69
+ isContainer: stats.isDirectory,
70
+ etag: stats.etag,
71
+ contentType,
72
+ origin
73
+ });
74
+
75
+ if (!stats.isDirectory) {
76
+ headers['Content-Length'] = stats.size;
77
+ }
78
+
79
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
80
+ return reply.code(200).send();
81
+ }
82
+
83
+ /**
84
+ * Handle PUT request
85
+ */
86
+ export async function handlePut(request, reply) {
87
+ const urlPath = request.url.split('?')[0];
88
+
89
+ // Don't allow PUT to containers
90
+ if (isContainer(urlPath)) {
91
+ return reply.code(409).send({ error: 'Cannot PUT to container. Use POST instead.' });
92
+ }
93
+
94
+ // Check if resource already exists
95
+ const existed = await storage.exists(urlPath);
96
+
97
+ // Get content from request body
98
+ let content = request.body;
99
+
100
+ // Handle raw body for non-JSON content types
101
+ if (Buffer.isBuffer(content)) {
102
+ // Already a buffer, use as-is
103
+ } else if (typeof content === 'string') {
104
+ content = Buffer.from(content);
105
+ } else if (content && typeof content === 'object') {
106
+ content = Buffer.from(JSON.stringify(content));
107
+ } else {
108
+ content = Buffer.from('');
109
+ }
110
+
111
+ const success = await storage.write(urlPath, content);
112
+ if (!success) {
113
+ return reply.code(500).send({ error: 'Write failed' });
114
+ }
115
+
116
+ const origin = request.headers.origin;
117
+ const headers = getAllHeaders({ isContainer: false, origin });
118
+ headers['Location'] = `${request.protocol}://${request.hostname}${urlPath}`;
119
+
120
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
121
+ return reply.code(existed ? 204 : 201).send();
122
+ }
123
+
124
+ /**
125
+ * Handle DELETE request
126
+ */
127
+ export async function handleDelete(request, reply) {
128
+ const urlPath = request.url.split('?')[0];
129
+
130
+ const existed = await storage.exists(urlPath);
131
+ if (!existed) {
132
+ return reply.code(404).send({ error: 'Not Found' });
133
+ }
134
+
135
+ const success = await storage.remove(urlPath);
136
+ if (!success) {
137
+ return reply.code(500).send({ error: 'Delete failed' });
138
+ }
139
+
140
+ const origin = request.headers.origin;
141
+ const headers = getAllHeaders({ isContainer: false, origin });
142
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
143
+
144
+ return reply.code(204).send();
145
+ }
146
+
147
+ /**
148
+ * Handle OPTIONS request
149
+ */
150
+ export async function handleOptions(request, reply) {
151
+ const urlPath = request.url.split('?')[0];
152
+ const stats = await storage.stat(urlPath);
153
+
154
+ const origin = request.headers.origin;
155
+ const headers = getAllHeaders({
156
+ isContainer: stats?.isDirectory || isContainer(urlPath),
157
+ origin
158
+ });
159
+
160
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
161
+ return reply.code(204).send();
162
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import { startServer } from './server.js';
2
+
3
+ const PORT = process.env.PORT || 3000;
4
+ const HOST = process.env.HOST || '0.0.0.0';
5
+
6
+ startServer(PORT, HOST).then(() => {
7
+ console.log(`JavaScript Solid Server running at http://${HOST}:${PORT}`);
8
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Generate container representation as JSON-LD
3
+ */
4
+
5
+ const LDP = 'http://www.w3.org/ns/ldp#';
6
+
7
+ /**
8
+ * Generate JSON-LD representation of a container
9
+ * @param {string} containerUrl - Full URL of the container
10
+ * @param {Array<{name: string, isDirectory: boolean}>} entries - Container contents
11
+ * @returns {object} - JSON-LD representation
12
+ */
13
+ export function generateContainerJsonLd(containerUrl, entries) {
14
+ // Ensure container URL ends with /
15
+ const baseUrl = containerUrl.endsWith('/') ? containerUrl : containerUrl + '/';
16
+
17
+ const contains = entries.map(entry => {
18
+ const childUrl = baseUrl + entry.name + (entry.isDirectory ? '/' : '');
19
+ return {
20
+ '@id': childUrl,
21
+ '@type': entry.isDirectory ? [`${LDP}Container`, `${LDP}BasicContainer`, `${LDP}Resource`] : [`${LDP}Resource`]
22
+ };
23
+ });
24
+
25
+ return {
26
+ '@context': {
27
+ 'ldp': LDP,
28
+ 'contains': { '@id': 'ldp:contains', '@type': '@id' }
29
+ },
30
+ '@id': baseUrl,
31
+ '@type': ['ldp:Container', 'ldp:BasicContainer', 'ldp:Resource'],
32
+ 'contains': contains
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Convert JSON-LD to string
38
+ * @param {object} jsonLd
39
+ * @returns {string}
40
+ */
41
+ export function serializeJsonLd(jsonLd) {
42
+ return JSON.stringify(jsonLd, null, 2);
43
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * LDP (Linked Data Platform) header utilities
3
+ */
4
+
5
+ const LDP = 'http://www.w3.org/ns/ldp#';
6
+
7
+ /**
8
+ * Get Link headers for a resource
9
+ * @param {boolean} isContainer
10
+ * @returns {string}
11
+ */
12
+ export function getLinkHeader(isContainer) {
13
+ const links = [`<${LDP}Resource>; rel="type"`];
14
+
15
+ if (isContainer) {
16
+ links.push(`<${LDP}Container>; rel="type"`);
17
+ links.push(`<${LDP}BasicContainer>; rel="type"`);
18
+ }
19
+
20
+ return links.join(', ');
21
+ }
22
+
23
+ /**
24
+ * Get standard LDP response headers
25
+ * @param {object} options
26
+ * @returns {object}
27
+ */
28
+ export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
29
+ const headers = {
30
+ 'Link': getLinkHeader(isContainer),
31
+ 'WAC-Allow': 'user="read write append control", public="read write append"',
32
+ 'Accept-Patch': 'application/sparql-update',
33
+ 'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
34
+ 'Vary': 'Accept, Authorization, Origin'
35
+ };
36
+
37
+ if (isContainer) {
38
+ headers['Accept-Post'] = '*/*';
39
+ }
40
+
41
+ if (etag) {
42
+ headers['ETag'] = etag;
43
+ }
44
+
45
+ if (contentType) {
46
+ headers['Content-Type'] = contentType;
47
+ }
48
+
49
+ return headers;
50
+ }
51
+
52
+ /**
53
+ * Get CORS headers
54
+ * @param {string} origin
55
+ * @returns {object}
56
+ */
57
+ export function getCorsHeaders(origin) {
58
+ return {
59
+ 'Access-Control-Allow-Origin': origin || '*',
60
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
61
+ 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, If-Match, If-None-Match, Link, Slug, Origin',
62
+ 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, WAC-Allow',
63
+ 'Access-Control-Allow-Credentials': 'true',
64
+ 'Access-Control-Max-Age': '86400'
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Get all headers combined
70
+ * @param {object} options
71
+ * @returns {object}
72
+ */
73
+ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
74
+ return {
75
+ ...getResponseHeaders({ isContainer, etag, contentType }),
76
+ ...getCorsHeaders(origin)
77
+ };
78
+ }
package/src/server.js ADDED
@@ -0,0 +1,68 @@
1
+ import Fastify from 'fastify';
2
+ import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
3
+ import { handlePost, handleCreatePod } from './handlers/container.js';
4
+ import { getCorsHeaders } from './ldp/headers.js';
5
+
6
+ /**
7
+ * Create and configure Fastify server
8
+ */
9
+ export function createServer(options = {}) {
10
+ const fastify = Fastify({
11
+ logger: options.logger ?? true,
12
+ trustProxy: true,
13
+ // Handle raw body for non-JSON content
14
+ bodyLimit: 10 * 1024 * 1024 // 10MB
15
+ });
16
+
17
+ // Add raw body parser for all content types
18
+ fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
19
+ done(null, body);
20
+ });
21
+
22
+ // Global CORS preflight
23
+ fastify.addHook('onRequest', async (request, reply) => {
24
+ // Add CORS headers to all responses
25
+ const corsHeaders = getCorsHeaders(request.headers.origin);
26
+ Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
27
+
28
+ // Handle preflight
29
+ if (request.method === 'OPTIONS') {
30
+ reply.code(204).send();
31
+ return reply;
32
+ }
33
+ });
34
+
35
+ // Pod creation endpoint
36
+ fastify.post('/.pods', handleCreatePod);
37
+
38
+ // LDP routes - using wildcard routing
39
+ fastify.get('/*', handleGet);
40
+ fastify.head('/*', handleHead);
41
+ fastify.put('/*', handlePut);
42
+ fastify.delete('/*', handleDelete);
43
+ fastify.post('/*', handlePost);
44
+ fastify.options('/*', handleOptions);
45
+
46
+ // Root route
47
+ fastify.get('/', handleGet);
48
+ fastify.head('/', handleHead);
49
+ fastify.options('/', handleOptions);
50
+ fastify.post('/', handlePost);
51
+
52
+ return fastify;
53
+ }
54
+
55
+ /**
56
+ * Start the server
57
+ */
58
+ export async function startServer(port = 3000, host = '0.0.0.0') {
59
+ const server = createServer();
60
+
61
+ try {
62
+ await server.listen({ port, host });
63
+ return server;
64
+ } catch (err) {
65
+ server.log.error(err);
66
+ process.exit(1);
67
+ }
68
+ }
@@ -0,0 +1,151 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { DATA_ROOT, urlToPath, isContainer } from '../utils/url.js';
5
+
6
+ // Ensure data directory exists
7
+ fs.ensureDirSync(DATA_ROOT);
8
+
9
+ /**
10
+ * Check if resource exists
11
+ * @param {string} urlPath
12
+ * @returns {Promise<boolean>}
13
+ */
14
+ export async function exists(urlPath) {
15
+ const filePath = urlToPath(urlPath);
16
+ return fs.pathExists(filePath);
17
+ }
18
+
19
+ /**
20
+ * Get resource stats
21
+ * @param {string} urlPath
22
+ * @returns {Promise<{isDirectory: boolean, size: number, mtime: Date, etag: string} | null>}
23
+ */
24
+ export async function stat(urlPath) {
25
+ const filePath = urlToPath(urlPath);
26
+
27
+ try {
28
+ const stats = await fs.stat(filePath);
29
+ return {
30
+ isDirectory: stats.isDirectory(),
31
+ size: stats.size,
32
+ mtime: stats.mtime,
33
+ etag: `"${crypto.createHash('md5').update(stats.mtime.toISOString() + stats.size).digest('hex')}"`
34
+ };
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read resource content
42
+ * @param {string} urlPath
43
+ * @returns {Promise<Buffer | null>}
44
+ */
45
+ export async function read(urlPath) {
46
+ const filePath = urlToPath(urlPath);
47
+
48
+ try {
49
+ return await fs.readFile(filePath);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Write resource content
57
+ * @param {string} urlPath
58
+ * @param {Buffer | string} content
59
+ * @returns {Promise<boolean>}
60
+ */
61
+ export async function write(urlPath, content) {
62
+ const filePath = urlToPath(urlPath);
63
+
64
+ try {
65
+ // Ensure parent directory exists
66
+ await fs.ensureDir(path.dirname(filePath));
67
+ await fs.writeFile(filePath, content);
68
+ return true;
69
+ } catch (err) {
70
+ console.error('Write error:', err);
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Delete resource
77
+ * @param {string} urlPath
78
+ * @returns {Promise<boolean>}
79
+ */
80
+ export async function remove(urlPath) {
81
+ const filePath = urlToPath(urlPath);
82
+
83
+ try {
84
+ await fs.remove(filePath);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create container (directory)
93
+ * @param {string} urlPath
94
+ * @returns {Promise<boolean>}
95
+ */
96
+ export async function createContainer(urlPath) {
97
+ const filePath = urlToPath(urlPath);
98
+
99
+ try {
100
+ await fs.ensureDir(filePath);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * List container contents
109
+ * @param {string} urlPath
110
+ * @returns {Promise<Array<{name: string, isDirectory: boolean}> | null>}
111
+ */
112
+ export async function listContainer(urlPath) {
113
+ const filePath = urlToPath(urlPath);
114
+
115
+ try {
116
+ const entries = await fs.readdir(filePath, { withFileTypes: true });
117
+ return entries.map(entry => ({
118
+ name: entry.name,
119
+ isDirectory: entry.isDirectory()
120
+ }));
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Generate unique filename for POST
128
+ * @param {string} containerPath
129
+ * @param {string} slug
130
+ * @param {boolean} isDir
131
+ * @returns {Promise<string>}
132
+ */
133
+ export async function generateUniqueFilename(containerPath, slug, isDir = false) {
134
+ const basePath = urlToPath(containerPath);
135
+ let name = slug || crypto.randomUUID();
136
+
137
+ // Remove any path traversal attempts
138
+ name = name.replace(/[/\\]/g, '-');
139
+
140
+ let candidate = path.join(basePath, name);
141
+ let counter = 1;
142
+
143
+ while (await fs.pathExists(candidate)) {
144
+ const ext = path.extname(name);
145
+ const base = path.basename(name, ext);
146
+ candidate = path.join(basePath, `${base}-${counter}${ext}`);
147
+ counter++;
148
+ }
149
+
150
+ return path.basename(candidate);
151
+ }
@@ -0,0 +1,83 @@
1
+ import path from 'path';
2
+
3
+ // Base directory for storing all pods
4
+ export const DATA_ROOT = process.env.DATA_ROOT || './data';
5
+
6
+ /**
7
+ * Convert URL path to filesystem path
8
+ * @param {string} urlPath - The URL path (e.g., /alice/profile/)
9
+ * @returns {string} - Filesystem path
10
+ */
11
+ export function urlToPath(urlPath) {
12
+ // Normalize: remove leading slash, decode URI
13
+ let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
14
+ normalized = decodeURIComponent(normalized);
15
+
16
+ // Security: prevent path traversal
17
+ normalized = normalized.replace(/\.\./g, '');
18
+
19
+ return path.join(DATA_ROOT, normalized);
20
+ }
21
+
22
+ /**
23
+ * Check if URL path represents a container (ends with /)
24
+ * @param {string} urlPath
25
+ * @returns {boolean}
26
+ */
27
+ export function isContainer(urlPath) {
28
+ return urlPath.endsWith('/');
29
+ }
30
+
31
+ /**
32
+ * Get the parent container path
33
+ * @param {string} urlPath
34
+ * @returns {string}
35
+ */
36
+ export function getParentContainer(urlPath) {
37
+ const parts = urlPath.replace(/\/$/, '').split('/');
38
+ parts.pop();
39
+ return parts.join('/') + '/';
40
+ }
41
+
42
+ /**
43
+ * Get resource name from URL path
44
+ * @param {string} urlPath
45
+ * @returns {string}
46
+ */
47
+ export function getResourceName(urlPath) {
48
+ const parts = urlPath.replace(/\/$/, '').split('/');
49
+ return parts[parts.length - 1];
50
+ }
51
+
52
+ /**
53
+ * Determine content type from file extension
54
+ * @param {string} filePath
55
+ * @returns {string}
56
+ */
57
+ export function getContentType(filePath) {
58
+ const ext = path.extname(filePath).toLowerCase();
59
+ const types = {
60
+ '.jsonld': 'application/ld+json',
61
+ '.json': 'application/json',
62
+ '.html': 'text/html',
63
+ '.txt': 'text/plain',
64
+ '.css': 'text/css',
65
+ '.js': 'application/javascript',
66
+ '.png': 'image/png',
67
+ '.jpg': 'image/jpeg',
68
+ '.jpeg': 'image/jpeg',
69
+ '.gif': 'image/gif',
70
+ '.svg': 'image/svg+xml',
71
+ '.pdf': 'application/pdf'
72
+ };
73
+ return types[ext] || 'application/octet-stream';
74
+ }
75
+
76
+ /**
77
+ * Check if content type is RDF
78
+ * @param {string} contentType
79
+ * @returns {boolean}
80
+ */
81
+ export function isRdfContentType(contentType) {
82
+ return contentType === 'application/ld+json' || contentType === 'application/json';
83
+ }